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