Skip to main content

release_kit/
stage.rs

1//! The candidate stage: this binary's projection for one target,
2//! materialized on disk beside the knowledge that explains it.
3//!
4//! A stage is evidence, never an installation transaction. `rk stage`
5//! gathers the target's evidence, computes the one pure [`Projection`],
6//! and writes the complete proposed bytes of every candidate under
7//! `artifacts/`, the installed binary's changelog, guidance, method,
8//! bindings, runbooks, forge notes, setup skill, and the shared resources
9//! that skill routes to under `reference/`, and one explanatory receipt,
10//! `stage.json`. It writes nothing inside the target. Production landing
11//! reads no byte of it, and only `rk stage clean` removes it.
12//!
13//! The stage is built whole under a fresh sibling of its resolved path and
14//! renamed into place after the receipt, so a stage that is visible is
15//! complete. The default root below the private state directory and the
16//! receipt are created owner-only.
17
18pub mod clean;
19
20use std::borrow::Cow;
21use std::fs::{self, File};
22use std::io::Write as _;
23use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _};
24use std::path::{Path, PathBuf};
25
26use camino::Utf8Path;
27use serde::{Deserialize, Serialize};
28
29use crate::applog;
30use crate::diagnostic::{Diagnostic, Reason};
31use crate::digest::Digest;
32use crate::embedded;
33use crate::error::RkError;
34use crate::landing::manifest::{Manifest, Provider, Style, Workflow};
35use crate::landing::{Kind, Params};
36use crate::projection::{Placement, Projection};
37use crate::skills;
38
39/// The shape version of the stage receipt and of the `rk stage` report.
40pub const STAGE_SCHEMA: &str = "rk.stage/3";
41
42/// The receipt's name at the stage root.
43pub const RECEIPT_NAME: &str = "stage.json";
44
45/// The variable naming an alternative base for the default stage path.
46pub const OUTPUT_ROOT_VAR: &str = "RK_STAGE_ROOT";
47
48/// The directory below the state root that holds the default stages.
49pub const STAGES_DIR: &str = "stages";
50
51/// The directory below the stage root holding the candidate tree.
52pub const ARTIFACTS_DIR: &str = "artifacts";
53
54/// The directory below the stage root holding the installed knowledge.
55pub const REFERENCE_DIR: &str = "reference";
56
57/// The skill whose installed text and routed resources the reference
58/// tree carries.
59pub const SETUP_SKILL: &str = "rk-setup";
60
61/// The interruption proof's seam: the stage-relative path after which a
62/// materialization stops on purpose, as if the write after it had failed.
63pub const INTERRUPT_VAR: &str = "RK_STAGE_INTERRUPT_AT";
64
65/// Every reference root a stage writes, in the order the receipt lists
66/// them.
67pub const REFERENCE_ROOTS: [&str; 8] = [
68    "CHANGELOG.md",
69    "guidance",
70    "method",
71    "bindings",
72    "runbooks",
73    "forges",
74    "skills/rk-setup",
75    "skill-shared",
76];
77
78/// The explanatory receipt a stage carries, and the document `rk stage
79/// --json` reports. Metadata only: no later command reads it as input.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct Receipt {
82    /// The shape version of this document.
83    pub schema: String,
84    /// The binary that staged.
85    pub rk_version: String,
86    /// The canonical absolute path of the target that was read.
87    pub target: String,
88    /// The canonical absolute path of the stage itself.
89    pub stage_root: String,
90    /// The resolved landing parameters the projection ran under.
91    pub parameters: Parameters,
92    /// The `schema_version` the target's landing record declares, where
93    /// the record is present and readable as JSON.
94    pub receipt_schema_version: Option<u64>,
95    /// One entry per candidate destination under `artifacts/`.
96    pub candidates: Vec<CandidateEntry>,
97    /// The destinations the target's own state withholds.
98    pub omissions: Vec<Note>,
99    /// The block destinations whose document offers the block no place.
100    pub collisions: Vec<Note>,
101    /// The destinations the landing record names that this projection no
102    /// longer produces: target-owned from the next landing on.
103    pub retired: Vec<String>,
104    /// The recorded `seeded` destinations present on disk, which a
105    /// production landing preserves.
106    pub seeded_present: Vec<String>,
107    /// The recorded `state` destinations present on disk, which a
108    /// production landing preserves.
109    pub state_present: Vec<String>,
110    /// The reference roots written under `reference/`.
111    pub reference: Vec<String>,
112}
113
114/// The resolved landing parameters, stated whole.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Parameters {
117    /// The binding selected.
118    pub tech: String,
119    /// The forge selected.
120    pub forge: String,
121    /// The project path on the forge.
122    pub repo: String,
123    /// The working-copy mode.
124    pub workflow: Workflow,
125    /// The release style, where one resolved.
126    pub style: Option<Style>,
127    /// Whether the landing carries the Nix capability.
128    pub nix: bool,
129    /// Whether the landing carries the Scorecard capability.
130    pub scorecard: bool,
131    /// The code scanning provider the landing carries, where one is
132    /// recorded.
133    pub code_scanning: Option<Provider>,
134    /// The one permanent branch.
135    pub trunk: String,
136    /// The release-line prefix.
137    pub line_prefix: String,
138    /// The security contact, empty for the forge's own wording.
139    pub security_contact: String,
140    /// The acknowledgment window.
141    pub security_response: String,
142}
143
144impl From<&Params> for Parameters {
145    fn from(params: &Params) -> Self {
146        Self {
147            tech: params.tech().to_owned(),
148            forge: params.forge().to_owned(),
149            repo: params.repo().to_owned(),
150            workflow: params.workflow(),
151            style: params.style(),
152            nix: params.nix(),
153            scorecard: params.scorecard(),
154            code_scanning: params.code_scanning(),
155            trunk: params.trunk().to_owned(),
156            line_prefix: params.line_prefix().to_owned(),
157            security_contact: params.security_contact().to_owned(),
158            security_response: params.security_response().to_owned(),
159        }
160    }
161}
162
163/// One candidate under `artifacts/`.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct CandidateEntry {
166    /// The destination, relative to the target root and to `artifacts/`.
167    pub destination: String,
168    /// Who owns the bytes after landing.
169    pub kind: Kind,
170    /// `whole` for a whole file, `region` for a marked region whose
171    /// artifact is the complete spliced document.
172    pub placement: String,
173    /// The digest of the complete artifact bytes.
174    pub sha256: Digest,
175    /// The digest of the rendered region alone, for a region destination.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub region_sha256: Option<Digest>,
178    /// The embedded source paths the candidate was rendered from.
179    pub sources: Vec<String>,
180}
181
182/// One destination named with a reason.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct Note {
185    /// The destination.
186    pub destination: String,
187    /// Why it is listed here.
188    pub reason: String,
189}
190
191/// Where the resolved output path came from.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum OutputSource {
194    /// `--output` named it.
195    Flag,
196    /// `RK_STAGE_ROOT` supplied the base.
197    Environment,
198    /// The private state root supplied the base.
199    StateRoot,
200}
201
202impl OutputSource {
203    /// The report form.
204    #[must_use]
205    pub const fn as_str(self) -> &'static str {
206        match self {
207            Self::Flag => "--output",
208            Self::Environment => "RK_STAGE_ROOT",
209            Self::StateRoot => "state root",
210        }
211    }
212}
213
214/// The filesystem-safe key naming one target below a stage base.
215///
216/// The digest of the canonical target path, the same derivation the
217/// target lock uses, so a path carrying a separator cannot name another
218/// target's stage.
219#[must_use]
220pub fn target_key(canonical_target: &Path) -> String {
221    Digest::of(canonical_target.display().to_string().as_bytes()).to_string()
222}
223
224/// Resolve the output directory: `--output` first, then a target and
225/// version directory below `RK_STAGE_ROOT`, then the same below the
226/// private state root.
227///
228/// # Errors
229///
230/// Returns a `prerequisite-unmet` refusal where neither a flag, the
231/// variable, nor a state root names a base.
232pub fn resolve_output(
233    flag: Option<&Utf8Path>,
234    canonical_target: &Path,
235) -> Result<(PathBuf, OutputSource), RkError> {
236    if let Some(flag) = flag {
237        let path = if flag.is_absolute() {
238            flag.as_std_path().to_path_buf()
239        } else {
240            std::env::current_dir()?.join(flag.as_std_path())
241        };
242        return Ok((path, OutputSource::Flag));
243    }
244    let leaf = Path::new(&target_key(canonical_target)).join(env!("CARGO_PKG_VERSION"));
245    if let Some(base) = std::env::var_os(OUTPUT_ROOT_VAR).filter(|value| !value.is_empty()) {
246        let base = PathBuf::from(base);
247        let base = if base.is_absolute() {
248            base
249        } else {
250            std::env::current_dir()?.join(base)
251        };
252        return Ok((base.join(leaf), OutputSource::Environment));
253    }
254    let Some(root) = applog::state_root() else {
255        return Err(RkError::refusal(
256            Diagnostic::new(
257                Reason::PrerequisiteUnmet,
258                "no state root resolves, so the stage has nowhere to go, and nothing was written",
259            )
260            .expected("--output <dir>, RK_STAGE_ROOT, or a state root under XDG_STATE_HOME or HOME")
261            .action("pass --output <dir>, or set XDG_STATE_HOME or HOME, and run it again")
262            .target_state("unchanged"),
263        ));
264    };
265    Ok((root.join(STAGES_DIR).join(leaf), OutputSource::StateRoot))
266}
267
268/// An output path checked and made ready: its parent exists and is
269/// canonical, the resolved stage root is known, and nothing nonempty
270/// stands there.
271#[derive(Debug)]
272pub struct Prepared {
273    parent: PathBuf,
274    name: std::ffi::OsString,
275    resolved: PathBuf,
276    owner_only: bool,
277}
278
279impl Prepared {
280    /// The canonical absolute path the stage will stand at.
281    #[must_use]
282    pub fn resolved(&self) -> &Path {
283        &self.resolved
284    }
285}
286
287/// The path `output` will stand at once created, computed without
288/// creating any component: the deepest existing ancestor canonicalized,
289/// the remaining components appended as named.
290///
291/// # Errors
292///
293/// A refusal for a remaining component that is `..`, which no stage path
294/// may carry, and [`RkError::Io`] where the existing ancestor cannot be
295/// canonicalized.
296fn eventual(output: &Path) -> Result<PathBuf, RkError> {
297    let mut existing = output;
298    let mut rest: Vec<&std::ffi::OsStr> = Vec::new();
299    loop {
300        match fs::symlink_metadata(existing) {
301            Ok(_) => break,
302            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
303            Err(error) => return Err(error.into()),
304        }
305        let Some(name) = existing.file_name() else {
306            break;
307        };
308        rest.push(name);
309        existing = existing.parent().unwrap_or_else(|| Path::new("/"));
310    }
311    let mut path = fs::canonicalize(if existing.as_os_str().is_empty() {
312        Path::new(".")
313    } else {
314        existing
315    })?;
316    for name in rest.into_iter().rev() {
317        if name == ".." {
318            return Err(RkError::refusal(
319                Diagnostic::new(
320                    Reason::Usage,
321                    format!(
322                        "{} climbs through a directory that does not exist yet, and nothing was written",
323                        output.display()
324                    ),
325                )
326                .expected("an output path whose absent components are plain names")
327                .target_state("unchanged"),
328            ));
329        }
330        if name != "." {
331            path.push(name);
332        }
333    }
334    Ok(path)
335}
336
337/// Resolve where the stage will stand, refuse a stage inside the target,
338/// refuse an existing nonempty output, create the parent, and name the
339/// canonical stage root.
340///
341/// Nothing is created before the stage root is known and judged against
342/// the target: a stage below the target would be a write inside the
343/// repository this verb promises to leave alone, whichever of the flag,
344/// the variable, or the state root put it there. Below the state root
345/// every directory this creates is owner-only, and a base that turns out
346/// to be a link or another file type refuses, because a private stage
347/// under a directory somebody else controls is not private.
348///
349/// # Errors
350///
351/// Returns a `destructive-refusal` for a stage root at or below the
352/// target, a `state-drift` refusal for an existing nonempty output, a
353/// refusal for an output whose final component is no name, and
354/// [`RkError::Io`] for a parent that cannot be created or read.
355pub fn prepare(
356    output: &Path,
357    source: OutputSource,
358    canonical_target: &Path,
359) -> Result<Prepared, RkError> {
360    let name = output
361        .file_name()
362        .filter(|name| *name != "." && *name != "..")
363        .ok_or_else(|| {
364            RkError::refusal(
365                Diagnostic::new(
366                    Reason::Usage,
367                    format!("{} names no directory to stage into", output.display()),
368                )
369                .expected("an output path ending in a directory name")
370                .target_state("unchanged"),
371            )
372        })?
373        .to_owned();
374    let eventual = eventual(output)?;
375    if eventual.starts_with(canonical_target) {
376        return Err(RkError::refusal(
377            Diagnostic::new(
378                Reason::DestructiveRefusal,
379                format!(
380                    "the stage would stand at {}, inside the target {}, and nothing was written",
381                    eventual.display(),
382                    canonical_target.display()
383                ),
384            )
385            .expected("a stage root outside the target repository")
386            .action(match source {
387                OutputSource::Flag => "pass an --output outside the target".to_owned(),
388                OutputSource::Environment => {
389                    format!("point {OUTPUT_ROOT_VAR} outside the target, or pass --output")
390                }
391                OutputSource::StateRoot => {
392                    "move the state root outside the target, or pass --output".to_owned()
393                }
394            })
395            .target_state("unchanged"),
396        ));
397    }
398    let parent = output
399        .parent()
400        .filter(|parent| !parent.as_os_str().is_empty())
401        .map_or_else(|| PathBuf::from("/"), Path::to_path_buf);
402    let owner_only = source == OutputSource::StateRoot;
403    if owner_only {
404        create_owner_only(&parent)?;
405    } else {
406        fs::create_dir_all(&parent)?;
407    }
408    let parent = fs::canonicalize(&parent)?;
409    let resolved = parent.join(&name);
410    match fs::symlink_metadata(&resolved) {
411        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
412        Err(error) => return Err(error.into()),
413        Ok(metadata) if metadata.is_dir() && fs::read_dir(&resolved)?.next().is_none() => {}
414        Ok(_) => {
415            return Err(RkError::refusal(
416                Diagnostic::new(
417                    Reason::StateDrift,
418                    format!(
419                        "{} already exists and is not empty, and nothing was written",
420                        resolved.display()
421                    ),
422                )
423                .expected("an absent or empty output directory")
424                .action(format!(
425                    "rk stage clean {} removes a stage that stands there; otherwise pass another --output",
426                    resolved.display()
427                ))
428                .target_state("unchanged"),
429            ));
430        }
431    }
432    Ok(Prepared {
433        parent,
434        name,
435        resolved,
436        owner_only,
437    })
438}
439
440/// Create the default base for a stage owner-only, component by
441/// component, and refuse a component that is a link or not a directory.
442fn create_owner_only(dir: &Path) -> std::io::Result<()> {
443    fs::DirBuilder::new()
444        .recursive(true)
445        .mode(0o700)
446        .create(dir)?;
447    // The state root itself belongs to every run-shaped artifact; the
448    // stage base below it and every component under that are private.
449    let Some(state_root) = applog::state_root() else {
450        return Ok(());
451    };
452    let base = state_root.join(STAGES_DIR);
453    let Ok(rest) = dir.strip_prefix(&base) else {
454        return Ok(());
455    };
456    let mut current = base;
457    restrict(&current)?;
458    for component in rest {
459        current.push(component);
460        restrict(&current)?;
461    }
462    Ok(())
463}
464
465/// Make one existing base component private, refusing a link or another
466/// file type.
467fn restrict(dir: &Path) -> std::io::Result<()> {
468    let metadata = fs::symlink_metadata(dir)?;
469    if metadata.file_type().is_symlink() || !metadata.is_dir() {
470        return Err(std::io::Error::new(
471            std::io::ErrorKind::InvalidData,
472            format!("stage base is not a directory: {}", dir.display()),
473        ));
474    }
475    fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
476}
477
478/// A stage composed and ready to write: every file with its
479/// stage-relative path, and the receipt.
480#[derive(Debug)]
481pub struct Composed {
482    /// Every file under the stage root except the receipt, sorted by path.
483    pub files: Vec<(String, Cow<'static, [u8]>)>,
484    /// The receipt, written last.
485    pub receipt: Receipt,
486}
487
488/// Compose the stage for `projection`, rooted at `stage_root`, from the
489/// projection, the resolved parameters, the target's record, and this
490/// binary's embedded knowledge.
491#[must_use]
492pub fn compose(
493    projection: &Projection,
494    params: &Params,
495    canonical_target: &Path,
496    stage_root: &Path,
497    record: Option<&Manifest>,
498    receipt_schema_version: Option<u64>,
499) -> Composed {
500    let mut files: Vec<(String, Cow<'static, [u8]>)> = Vec::new();
501    let mut candidates = Vec::new();
502    for candidate in &projection.candidates {
503        files.push((
504            format!("{ARTIFACTS_DIR}/{}", candidate.destination),
505            Cow::Owned(candidate.bytes.clone()),
506        ));
507        candidates.push(CandidateEntry {
508            destination: candidate.destination.clone(),
509            kind: candidate.kind,
510            placement: match candidate.placement {
511                Placement::Whole => "whole",
512                Placement::Region { .. } => "region",
513            }
514            .to_owned(),
515            sha256: Digest::of(&candidate.bytes),
516            region_sha256: candidate.region.as_deref().map(Digest::of),
517            sources: candidate.sources.clone(),
518        });
519    }
520    for (path, bytes) in reference_files() {
521        files.push((format!("{REFERENCE_DIR}/{path}"), Cow::Borrowed(bytes)));
522    }
523    files.sort_by(|a, b| a.0.cmp(&b.0));
524    let produced = |destination: &str| {
525        projection
526            .candidates
527            .iter()
528            .any(|candidate| candidate.destination == destination)
529            || projection
530                .omissions
531                .iter()
532                .any(|omission| omission.destination == destination)
533    };
534    let mut retired = Vec::new();
535    let mut seeded_present = Vec::new();
536    let mut state_present = Vec::new();
537    if let Some(record) = record {
538        for file in &record.files {
539            if !produced(&file.destination) {
540                retired.push(file.destination.clone());
541            }
542            let present = fs::symlink_metadata(canonical_target.join(&file.destination)).is_ok();
543            match file.kind {
544                Kind::Seeded if present => seeded_present.push(file.destination.clone()),
545                Kind::State if present => state_present.push(file.destination.clone()),
546                Kind::Rendered | Kind::Seeded | Kind::State => {}
547            }
548        }
549    }
550    let receipt = Receipt {
551        schema: STAGE_SCHEMA.to_owned(),
552        rk_version: env!("CARGO_PKG_VERSION").to_owned(),
553        target: canonical_target.display().to_string(),
554        stage_root: stage_root.display().to_string(),
555        parameters: Parameters::from(params),
556        receipt_schema_version,
557        candidates,
558        omissions: projection
559            .omissions
560            .iter()
561            .map(|omission| Note {
562                destination: omission.destination.clone(),
563                reason: omission.reason.clone(),
564            })
565            .collect(),
566        collisions: projection
567            .collisions
568            .iter()
569            .map(|collision| Note {
570                destination: collision.destination.clone(),
571                reason: collision.reason.clone(),
572            })
573            .collect(),
574        retired,
575        seeded_present,
576        state_present,
577        reference: REFERENCE_ROOTS
578            .iter()
579            .map(|root| (*root).to_owned())
580            .collect(),
581    };
582    Composed { files, receipt }
583}
584
585/// Every file the reference tree carries, as `(path, bytes)` below
586/// `reference/`.
587///
588/// From the embedded sources and from nowhere else: the changelog, every
589/// guidance file, the method, the bindings, the runbooks, the forge
590/// documents, the setup skill as installed, and the shared resources that
591/// skill names.
592#[must_use]
593pub fn reference_files() -> Vec<(String, &'static [u8])> {
594    let mut out: Vec<(String, &'static [u8])> =
595        vec![("CHANGELOG.md".to_owned(), embedded::CHANGELOG.as_bytes())];
596    for (root, dir) in [
597        ("guidance", &embedded::GUIDANCE),
598        ("method", &embedded::METHOD),
599        ("bindings", &embedded::BINDINGS),
600        ("runbooks", &embedded::RUNBOOKS),
601        ("forges", &embedded::FORGES),
602    ] {
603        for (path, bytes) in embedded::walk(dir) {
604            out.push((format!("{root}/{path}"), bytes));
605        }
606    }
607    let prefix = format!("{SETUP_SKILL}/");
608    let mut skill_text = String::new();
609    for (path, bytes) in embedded::walk(&embedded::SKILLS) {
610        if path.starts_with(&prefix) {
611            if path == format!("{prefix}SKILL.md") {
612                skill_text = String::from_utf8_lossy(bytes).into_owned();
613            }
614            out.push((format!("skills/{path}"), bytes));
615        }
616    }
617    for artifact in skills::shared() {
618        if skill_text.contains(&artifact.path) {
619            out.push((format!("skill-shared/{}", artifact.path), artifact.bytes));
620        }
621    }
622    out
623}
624
625/// How many sibling names a write tries before it refuses.
626const TEMP_ATTEMPTS: u32 = 8;
627
628/// The proof's seam for the failure cleanup: a directory where a stopped
629/// write announces `stopped` before it quarantines its sibling and waits
630/// for `proceed`.
631pub const PAUSE_BEFORE_CLEANUP_VAR: &str = "RK_STAGE_PAUSE_BEFORE_CLEANUP";
632
633/// The prefix of a quarantined sibling's name.
634const QUARANTINE_PREFIX: &str = ".rk-stage-quarantine-";
635
636/// The prefix of a finished sibling's claim name, the unpredictable name
637/// it is judged under before it is published.
638const CLAIM_PREFIX: &str = ".rk-stage-claim-";
639
640/// The proof's seam before publication: a finished write announces
641/// `finished` under this directory before it claims its sibling and
642/// waits for `proceed`.
643pub const PAUSE_BEFORE_LAND_VAR: &str = "RK_STAGE_PAUSE_BEFORE_LAND";
644
645/// The proof's seam after publication.
646///
647/// A write announces `landed` under this directory after the rename and
648/// before it checks that the public parent pathname still names the held
649/// parent, then waits for `proceed`.
650pub const PAUSE_AFTER_LAND_VAR: &str = "RK_STAGE_PAUSE_AFTER_LAND";
651
652/// The sibling name for one attempt: the first names this process alone,
653/// and every retry adds a nonce, so an entry somebody else left under the
654/// first name is stepped around rather than reused.
655fn temp_name(name: &std::ffi::OsStr, attempt: u32) -> std::ffi::OsString {
656    let mut out = std::ffi::OsString::from(format!(".rk-stage-{}", std::process::id()));
657    if attempt > 0 {
658        out.push(format!("-{:08x}", crate::held::nonce() & 0xffff_ffff));
659    }
660    out.push(".");
661    out.push(name);
662    out
663}
664
665/// The sibling this write created and holds: its name under the held
666/// parent, the open directory, and the identity the directory had the
667/// moment it was opened, which every later act on it is judged against.
668struct Temp {
669    name: std::ffi::OsString,
670    dir: File,
671    identity: crate::held::Identity,
672}
673
674/// Create the fresh sibling this write owns, exclusively, and hold it
675/// open: a name that already exists is never entered or removed, and the
676/// next name is tried instead, a bounded number of times.
677fn create_temp(prepared: &Prepared, parent: &File) -> std::io::Result<Temp> {
678    let mut builder = fs::DirBuilder::new();
679    if prepared.owner_only {
680        builder.mode(0o700);
681    }
682    let base = crate::held::proc_path(parent);
683    for attempt in 0..TEMP_ATTEMPTS {
684        let name = temp_name(&prepared.name, attempt);
685        match builder.create(base.join(&name)) {
686            Ok(()) => {
687                let dir = crate::held::open_dir(&base.join(&name))?;
688                let identity = crate::held::Identity::of(&dir.metadata()?);
689                return Ok(Temp {
690                    name,
691                    dir,
692                    identity,
693                });
694            }
695            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
696            Err(error) => return Err(error),
697        }
698    }
699    Err(std::io::Error::new(
700        std::io::ErrorKind::AlreadyExists,
701        format!(
702            "every sibling name for the stage below {} is taken, and nothing was written or removed",
703            prepared.parent.display()
704        ),
705    ))
706}
707
708/// Write the composed stage whole, then rename it into place.
709///
710/// Every file goes into a fresh sibling of the resolved root that this
711/// write created exclusively and holds open, written through the held
712/// descriptor rather than by name; the receipt goes last and owner-only;
713/// then, once the entry under the sibling's name still carries the
714/// created identity, one rename lands it. A failure anywhere quarantines
715/// the entry under an unpredictable name in the same parent, judges it
716/// against the created identity, and removes it only on a match: an
717/// entry this write did not create is never removed.
718///
719/// # Errors
720///
721/// Any I/O failure, including the injected stop of the interruption
722/// proof, which reports as an I/O failure naming the path it stopped at.
723pub fn write(prepared: &Prepared, composed: &Composed) -> Result<(), RkError> {
724    let stop = std::env::var_os(INTERRUPT_VAR).map(PathBuf::from);
725    write_stopping_at(prepared, composed, stop.as_deref())
726}
727
728/// [`write`], stopped on purpose after the file at `stop`, as if the
729/// write after it had failed: the interruption proof's seam.
730///
731/// # Errors
732///
733/// As [`write`], plus the injected stop.
734pub fn write_stopping_at(
735    prepared: &Prepared,
736    composed: &Composed,
737    stop: Option<&Path>,
738) -> Result<(), RkError> {
739    let parent = crate::held::open_dir(&prepared.parent)?;
740    let temp = create_temp(prepared, &parent)?;
741    if let Err(error) = write_into(&temp, composed, stop) {
742        return Err(RkError::Io(cleanup(
743            &parent,
744            &temp.name,
745            temp.identity,
746            error,
747        )));
748    }
749    land(&parent, &temp, prepared).map_err(RkError::Io)
750}
751
752/// Publish the finished sibling, every operand resolved through the held
753/// parent descriptor.
754///
755/// The sibling is first claimed: renamed to an unpredictable name under
756/// the held parent, then judged there against the created identity, so
757/// nothing exchanged under the sibling's name can be published. The
758/// claimed entry is renamed to the stage's name under the same
759/// descriptor. Afterwards the public parent pathname is checked to still
760/// name the held parent; where it does not, the stage just published is
761/// quarantined, judged, removed through the descriptor, and the run
762/// fails, because a stage nobody can reach by the path it was promised
763/// at is not a stage, and one reachable through a replaced parent might
764/// be anywhere.
765fn land(parent: &File, temp: &Temp, prepared: &Prepared) -> std::io::Result<()> {
766    crate::held::pause(PAUSE_BEFORE_LAND_VAR, "finished", "proceed");
767    let base = crate::held::proc_path(parent);
768    let (claim, current) = crate::held::quarantine(parent, &temp.name, CLAIM_PREFIX)?;
769    if current.file_type().is_symlink() || crate::held::Identity::of(&current) != temp.identity {
770        return Err(std::io::Error::other(format!(
771            "the sibling under {} was exchanged before the stage could land; the entry that took its name was moved to {} beside it and left in place, and nothing was published",
772            prepared.parent.join(&temp.name).display(),
773            claim.display()
774        )));
775    }
776    if let Err(error) = fs::rename(base.join(&claim), base.join(&prepared.name)) {
777        return Err(cleanup(parent, &claim, temp.identity, error));
778    }
779    crate::held::pause(PAUSE_AFTER_LAND_VAR, "landed", "proceed");
780    let public = fs::metadata(&prepared.parent)
781        .ok()
782        .map(|metadata| crate::held::Identity::of(&metadata));
783    let held_parent = crate::held::Identity::of(&parent.metadata()?);
784    if public == Some(held_parent) {
785        return Ok(());
786    }
787    Err(cleanup(
788        parent,
789        &prepared.name,
790        temp.identity,
791        std::io::Error::other(format!(
792            "the parent {} was replaced after it was opened, so the stage published under it is not where it was promised; it was removed again through the held descriptor",
793            prepared.parent.display()
794        )),
795    ))
796}
797
798/// The failure cleanup: quarantine whatever stands under `name` in the
799/// held parent, judge it against `identity`, and remove it only on a
800/// match. Returns `error` annotated with what was left where.
801fn cleanup(
802    parent: &File,
803    name: &std::ffi::OsStr,
804    identity: crate::held::Identity,
805    error: std::io::Error,
806) -> std::io::Error {
807    crate::held::pause(PAUSE_BEFORE_CLEANUP_VAR, "stopped", "proceed");
808    let base = crate::held::proc_path(parent);
809    let (quarantined, current) = match crate::held::quarantine(parent, name, QUARANTINE_PREFIX) {
810        Ok(moved) => moved,
811        Err(quarantine) => {
812            return std::io::Error::new(
813                error.kind(),
814                format!(
815                    "{error}; the entry under {} could not be quarantined and was left in place: {quarantine}",
816                    name.display()
817                ),
818            );
819        }
820    };
821    if current.file_type().is_symlink() || crate::held::Identity::of(&current) != identity {
822        return std::io::Error::new(
823            error.kind(),
824            format!(
825                "{error}; the entry under {} was not the directory this run created, so it was moved to {} and left in place",
826                name.display(),
827                quarantined.display()
828            ),
829        );
830    }
831    match fs::remove_dir_all(base.join(&quarantined)) {
832        Ok(()) => error,
833        Err(removal) => std::io::Error::new(
834            error.kind(),
835            format!(
836                "{error}; the directory was moved to {} and could not be removed: {removal}",
837                quarantined.display()
838            ),
839        ),
840    }
841}
842
843/// The body of [`write`]: every file into the held sibling, then the
844/// receipt, each addressed through the descriptor.
845fn write_into(temp: &Temp, composed: &Composed, stop: Option<&Path>) -> std::io::Result<()> {
846    let base = crate::held::proc_path(&temp.dir);
847    for (path, bytes) in &composed.files {
848        let destination = base.join(path);
849        if let Some(parent) = destination.parent() {
850            fs::create_dir_all(parent)?;
851        }
852        fs::write(&destination, bytes)?;
853        if stop.is_some_and(|stop| Path::new(path) == stop) {
854            return Err(std::io::Error::other(format!(
855                "the stage was stopped after {path} for the proof"
856            )));
857        }
858    }
859    let text = serde_json::to_string_pretty(&composed.receipt).map_err(std::io::Error::other)?;
860    let mut receipt = fs::OpenOptions::new()
861        .write(true)
862        .create_new(true)
863        .mode(0o600)
864        .open(base.join(RECEIPT_NAME))?;
865    receipt.write_all(text.as_bytes())?;
866    receipt.write_all(b"\n")?;
867    receipt.sync_all()?;
868    Ok(())
869}
870
871/// The `schema_version` the target's landing record declares, read
872/// leniently: `None` where no record exists or it does not parse as a
873/// JSON object carrying an integer there. Explanatory, never a gate.
874#[must_use]
875pub fn recorded_schema_version(target: &Utf8Path) -> Option<u64> {
876    let bytes = fs::read(target.join(crate::landing::manifest::MANIFEST_PATH)).ok()?;
877    let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
878    value.get("schema_version")?.as_u64()
879}
880
881#[cfg(test)]
882mod tests {
883    use super::{
884        CandidateEntry, Note, Parameters, REFERENCE_ROOTS, Receipt, STAGE_SCHEMA, reference_files,
885        target_key,
886    };
887    use crate::digest::Digest;
888    use crate::landing::Kind;
889    use crate::landing::manifest::{Style, Workflow};
890
891    /// The complete `rk.stage/3` receipt shape, held by snapshot: a field
892    /// rename or removal fails here and becomes a schema-version bump.
893    #[test]
894    fn the_stage_receipt_schema_snapshot_holds() {
895        let receipt = Receipt {
896            schema: STAGE_SCHEMA.to_owned(),
897            rk_version: "0.0.0".into(),
898            target: "/tmp/t".into(),
899            stage_root: "/tmp/s".into(),
900            parameters: Parameters {
901                tech: "rust".into(),
902                forge: "github".into(),
903                repo: "acme/widget".into(),
904                workflow: Workflow::Worktree,
905                style: Some(Style::Trunk),
906                nix: false,
907                scorecard: false,
908                code_scanning: None,
909                trunk: "master".into(),
910                line_prefix: "release/".into(),
911                security_contact: String::new(),
912                security_response: "best-effort".into(),
913            },
914            receipt_schema_version: Some(6),
915            candidates: vec![
916                CandidateEntry {
917                    destination: "AGENTS.md".into(),
918                    kind: Kind::Rendered,
919                    placement: "region".into(),
920                    sha256: Digest::of(b"a"),
921                    region_sha256: Some(Digest::of(b"r")),
922                    sources: vec!["blocks/routing.md.in".into()],
923                },
924                CandidateEntry {
925                    destination: "release-plz.toml".into(),
926                    kind: Kind::Seeded,
927                    placement: "whole".into(),
928                    sha256: Digest::of(b"b"),
929                    region_sha256: None,
930                    sources: vec!["snippets/rust/github/release-plz.toml".into()],
931                },
932            ],
933            omissions: vec![Note {
934                destination: "flake.nix".into(),
935                reason: "the target already carries flake.nix".into(),
936            }],
937            collisions: vec![],
938            retired: vec!["old.yml".into()],
939            seeded_present: vec!["release-plz.toml".into()],
940            state_present: vec![],
941            reference: REFERENCE_ROOTS
942                .iter()
943                .map(|root| (*root).to_owned())
944                .collect(),
945        };
946        assert_eq!(
947            serde_json::to_string(&receipt).expect("a receipt serializes"),
948            format!(
949                r#"{{"schema":"rk.stage/3","rk_version":"0.0.0","target":"/tmp/t","stage_root":"/tmp/s","parameters":{{"tech":"rust","forge":"github","repo":"acme/widget","workflow":"worktree","style":"trunk","nix":false,"scorecard":false,"code_scanning":null,"trunk":"master","line_prefix":"release/","security_contact":"","security_response":"best-effort"}},"receipt_schema_version":6,"candidates":[{{"destination":"AGENTS.md","kind":"rendered","placement":"region","sha256":"{}","region_sha256":"{}","sources":["blocks/routing.md.in"]}},{{"destination":"release-plz.toml","kind":"seeded","placement":"whole","sha256":"{}","sources":["snippets/rust/github/release-plz.toml"]}}],"omissions":[{{"destination":"flake.nix","reason":"the target already carries flake.nix"}}],"collisions":[],"retired":["old.yml"],"seeded_present":["release-plz.toml"],"state_present":[],"reference":["CHANGELOG.md","guidance","method","bindings","runbooks","forges","skills/rk-setup","skill-shared"]}}"#,
950                Digest::of(b"a"),
951                Digest::of(b"r"),
952                Digest::of(b"b")
953            )
954        );
955        let back: Receipt =
956            serde_json::from_str(&serde_json::to_string(&receipt).expect("serializes"))
957                .expect("a receipt reads back");
958        assert_eq!(back.stage_root, "/tmp/s");
959    }
960
961    /// Every declared reference root is served by at least one file, and
962    /// no file reaches outside the declared roots.
963    #[test]
964    fn every_reference_root_serves_a_file_and_nothing_else_is_served() {
965        let files = reference_files();
966        for root in REFERENCE_ROOTS {
967            assert!(
968                files
969                    .iter()
970                    .any(|(path, _)| path == root || path.starts_with(&format!("{root}/"))),
971                "{root}: the reference tree carries no file for it"
972            );
973        }
974        for (path, _) in &files {
975            assert!(
976                REFERENCE_ROOTS
977                    .iter()
978                    .any(|root| path == root || path.starts_with(&format!("{root}/"))),
979                "{path}: outside every declared reference root"
980            );
981            assert!(
982                !path
983                    .split('/')
984                    .any(|part| part == "_docs" || part == "tests" || part == "src"),
985                "{path}: an instance-owned or source path in the reference tree"
986            );
987        }
988    }
989
990    /// A sibling somebody else left under the exact first candidate name
991    /// is neither entered nor removed: the write steps to the next name,
992    /// lands, and every byte of the stranger survives.
993    #[test]
994    fn a_pre_existing_temp_sibling_is_never_touched() {
995        let scratch = tempfile::tempdir().expect("a scratch dir exists");
996        let parent = std::fs::canonicalize(scratch.path()).expect("canonical");
997        let output = parent.join("stage");
998        let target = parent.join("target");
999        std::fs::create_dir(&target).expect("creates");
1000        let prepared =
1001            super::prepare(&output, super::OutputSource::Flag, &target).expect("prepares");
1002        let stranger = parent.join(super::temp_name(std::ffi::OsStr::new("stage"), 0));
1003        std::fs::create_dir_all(stranger.join("deep")).expect("creates");
1004        std::fs::write(stranger.join("deep/canary"), b"not yours").expect("writes");
1005        std::fs::write(stranger.join("canary"), b"still not yours").expect("writes");
1006        let composed = super::Composed {
1007            files: vec![(
1008                "artifacts/a.txt".to_owned(),
1009                std::borrow::Cow::Borrowed(b"a"),
1010            )],
1011            receipt: sample_receipt(),
1012        };
1013        super::write(&prepared, &composed).expect("the write lands beside the stranger");
1014        assert_eq!(
1015            std::fs::read(output.join("artifacts/a.txt")).expect("reads"),
1016            b"a"
1017        );
1018        assert_eq!(
1019            std::fs::read(stranger.join("deep/canary")).expect("the stranger reads"),
1020            b"not yours"
1021        );
1022        assert_eq!(
1023            std::fs::read(stranger.join("canary")).expect("the stranger reads"),
1024            b"still not yours"
1025        );
1026        // The interrupted variant removes only what it created.
1027        let output_two = parent.join("stage-two");
1028        let prepared =
1029            super::prepare(&output_two, super::OutputSource::Flag, &target).expect("prepares");
1030        let stranger_two = parent.join(super::temp_name(std::ffi::OsStr::new("stage-two"), 0));
1031        std::fs::create_dir(&stranger_two).expect("creates");
1032        std::fs::write(stranger_two.join("canary"), b"kept").expect("writes");
1033        let stopped = super::write_stopping_at(
1034            &prepared,
1035            &composed,
1036            Some(std::path::Path::new("artifacts/a.txt")),
1037        );
1038        assert!(stopped.is_err());
1039        assert!(!output_two.exists());
1040        assert_eq!(
1041            std::fs::read(stranger_two.join("canary")).expect("the stranger reads"),
1042            b"kept"
1043        );
1044        let leftovers: Vec<String> = std::fs::read_dir(&parent)
1045            .expect("reads")
1046            .map(|entry| {
1047                entry
1048                    .expect("an entry")
1049                    .file_name()
1050                    .to_string_lossy()
1051                    .into_owned()
1052            })
1053            .filter(|name| name.starts_with(".rk-stage-"))
1054            .collect();
1055        assert_eq!(
1056            leftovers.len(),
1057            2,
1058            "only the two strangers remain: {leftovers:?}"
1059        );
1060    }
1061
1062    /// A stage root at or below the target refuses before any component
1063    /// is created, whichever source named it.
1064    #[test]
1065    fn a_stage_root_inside_the_target_refuses_before_anything_is_created() {
1066        let scratch = tempfile::tempdir().expect("a scratch dir exists");
1067        let target = std::fs::canonicalize(scratch.path())
1068            .expect("canonical")
1069            .join("t");
1070        std::fs::create_dir(&target).expect("creates");
1071        for (output, source) in [
1072            (target.join("stage"), super::OutputSource::Flag),
1073            (
1074                target.join("deep/er/stage"),
1075                super::OutputSource::Environment,
1076            ),
1077            (
1078                target.join("state/release-kit/stages/k/v"),
1079                super::OutputSource::StateRoot,
1080            ),
1081            (target.clone(), super::OutputSource::Flag),
1082        ] {
1083            let error = super::prepare(&output, source, &target).expect_err("refuses");
1084            assert_eq!(
1085                error.reason(),
1086                crate::diagnostic::Reason::DestructiveRefusal
1087            );
1088            assert_eq!(error.exit_code(), 73);
1089        }
1090        assert_eq!(
1091            std::fs::read_dir(&target).expect("reads").count(),
1092            0,
1093            "a refusal created a component inside the target"
1094        );
1095    }
1096
1097    fn sample_receipt() -> Receipt {
1098        Receipt {
1099            schema: STAGE_SCHEMA.to_owned(),
1100            rk_version: "0.0.0".into(),
1101            target: "/tmp/t".into(),
1102            stage_root: "/tmp/s".into(),
1103            parameters: Parameters {
1104                tech: "rust".into(),
1105                forge: "github".into(),
1106                repo: "acme/widget".into(),
1107                workflow: Workflow::Worktree,
1108                style: Some(Style::Trunk),
1109                nix: false,
1110                scorecard: false,
1111                code_scanning: None,
1112                trunk: "master".into(),
1113                line_prefix: "release/".into(),
1114                security_contact: String::new(),
1115                security_response: "best-effort".into(),
1116            },
1117            receipt_schema_version: None,
1118            candidates: vec![],
1119            omissions: vec![],
1120            collisions: vec![],
1121            retired: vec![],
1122            seeded_present: vec![],
1123            state_present: vec![],
1124            reference: vec![],
1125        }
1126    }
1127
1128    /// The key is the lock's derivation: one digest per canonical path,
1129    /// and a separator in the path cannot escape the base.
1130    #[test]
1131    fn the_target_key_is_one_flat_digest() {
1132        let key = target_key(std::path::Path::new("/a/b"));
1133        assert_eq!(key.len(), 64);
1134        assert!(key.bytes().all(|b| b.is_ascii_hexdigit()));
1135        assert_ne!(key, target_key(std::path::Path::new("/a/c")));
1136    }
1137}