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