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