1pub 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
41pub const STAGE_SCHEMA: &str = "rk.stage/7";
43
44pub const RECEIPT_NAME: &str = "stage.json";
46
47pub const OUTPUT_ROOT_VAR: &str = "RK_STAGE_ROOT";
49
50pub const STAGES_DIR: &str = "stages";
52
53pub const ARTIFACTS_DIR: &str = "artifacts";
55
56pub const REFERENCE_DIR: &str = "reference";
58
59pub const INTERRUPT_VAR: &str = "RK_STAGE_INTERRUPT_AT";
62
63pub const REFERENCE_ROOTS: [&str; 7] = [
66 "CHANGELOG.md",
67 "guidance",
68 "method",
69 "bindings",
70 "runbooks",
71 "forges",
72 "skills",
73];
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct Receipt {
79 pub schema: String,
81 pub rk_version: String,
83 pub target: String,
85 pub stage_root: String,
87 pub parameters: Parameters,
89 pub capabilities: Vec<CapabilityNote>,
91 pub receipt_schema_version: Option<u64>,
94 pub candidates: Vec<CandidateEntry>,
96 pub omissions: Vec<Note>,
98 pub collisions: Vec<Note>,
100 pub retired: Vec<String>,
103 pub seeded_present: Vec<String>,
106 pub state_present: Vec<String>,
109 #[serde(default)]
113 pub secrets_moved: Vec<crate::landing::apply::SecretMove>,
114 pub reference: Vec<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct Parameters {
121 pub profile: ProfileSnapshot,
123 pub git: GitWorkflow,
125 pub capabilities: CapabilityRequests,
127 pub repo: String,
129 pub security_contact: String,
131 pub security_response: String,
133 #[serde(default, skip_serializing_if = "String::is_empty")]
136 pub security_scope: String,
137 #[serde(default)]
139 pub required_check: String,
140 #[serde(default)]
143 pub required_workflow: String,
144 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct CandidateEntry {
170 pub destination: String,
172 pub kind: Kind,
174 pub placement: String,
177 pub sha256: Digest,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub region_sha256: Option<Digest>,
182 pub sources: Vec<String>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct Note {
189 pub destination: String,
191 pub reason: String,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub action: Option<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct CapabilityNote {
202 pub id: String,
204 pub status: String,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub reason: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub action: Option<String>,
213 pub destinations: Vec<String>,
215}
216
217impl CapabilityNote {
218 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum OutputSource {
240 Flag,
242 Environment,
244 StateRoot,
246}
247
248impl OutputSource {
249 #[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#[must_use]
266pub fn target_key(canonical_target: &Path) -> String {
267 Digest::of(canonical_target.display().to_string().as_bytes()).to_string()
268}
269
270pub 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#[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 #[must_use]
328 pub fn resolved(&self) -> &Path {
329 &self.resolved
330 }
331}
332
333fn 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
383pub 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
486fn create_owner_only(dir: &Path) -> std::io::Result<()> {
489 fs::DirBuilder::new()
490 .recursive(true)
491 .mode(0o700)
492 .create(dir)?;
493 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(¤t)?;
504 for component in rest {
505 current.push(component);
506 restrict(¤t)?;
507 }
508 Ok(())
509}
510
511fn 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#[derive(Debug)]
527pub struct Composed {
528 pub files: Vec<(String, Cow<'static, [u8]>)>,
530 pub receipt: Receipt,
532}
533
534#[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
640fn 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(¤t),
659 &String::from_utf8_lossy(&candidate.bytes),
660 )
661 })
662 })
663 .flatten()
664 .collect()
665}
666
667#[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 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
699const TEMP_ATTEMPTS: u32 = 8;
701
702pub const PAUSE_BEFORE_CLEANUP_VAR: &str = "RK_STAGE_PAUSE_BEFORE_CLEANUP";
706
707const QUARANTINE_PREFIX: &str = ".rk-stage-quarantine-";
709
710const CLAIM_PREFIX: &str = ".rk-stage-claim-";
713
714pub const PAUSE_BEFORE_LAND_VAR: &str = "RK_STAGE_PAUSE_BEFORE_LAND";
718
719pub const PAUSE_AFTER_LAND_VAR: &str = "RK_STAGE_PAUSE_AFTER_LAND";
725
726fn 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
739struct Temp {
743 name: std::ffi::OsString,
744 dir: File,
745 identity: crate::held::Identity,
746}
747
748fn 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
782pub 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
802pub 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
826fn 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(¤t) != 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
872fn 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(¤t) != 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
917fn 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
945pub 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#[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 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 #[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 #[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 #[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 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 #[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 #[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}