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