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/9";
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>,
104 #[serde(default)]
108 pub dropped: Vec<String>,
109 pub seeded_present: Vec<String>,
112 pub state_present: Vec<String>,
115 #[serde(default)]
119 pub secrets_moved: Vec<crate::landing::apply::SecretMove>,
120 pub reference: Vec<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Parameters {
127 pub profile: ProfileSnapshot,
129 pub git: GitWorkflow,
131 pub capabilities: CapabilityRequests,
133 pub repo: String,
135 pub security_contact: String,
137 pub security_response: String,
139 #[serde(default, skip_serializing_if = "String::is_empty")]
142 pub security_scope: String,
143 #[serde(default)]
146 pub required_check: String,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub dev_environment: Option<crate::config::dev_environment::DevEnvironment>,
151}
152
153impl From<&Params> for Parameters {
154 fn from(params: &Params) -> Self {
155 Self {
156 profile: params.profile().clone(),
157 git: params.git().clone(),
158 capabilities: params.capabilities().clone(),
159 repo: params.repo().to_owned(),
160 security_contact: params.security_contact().to_owned(),
161 security_response: params.security_response().to_owned(),
162 security_scope: params.security_scope().to_owned(),
163 required_check: params.required_check().to_owned(),
164 dev_environment: params.dev_environment().cloned(),
165 }
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct CandidateEntry {
172 pub destination: String,
174 pub kind: Kind,
176 pub placement: String,
179 pub sha256: Digest,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub region_sha256: Option<Digest>,
184 pub sources: Vec<String>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Note {
191 pub destination: String,
193 pub reason: String,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub action: Option<String>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct CapabilityNote {
204 pub id: String,
206 pub status: String,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub reason: Option<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub action: Option<String>,
215 pub destinations: Vec<String>,
217}
218
219impl CapabilityNote {
220 #[must_use]
223 pub fn of(selection: &crate::profile::catalog::Selection, projection: &Projection) -> Self {
224 Self {
225 id: selection.id.to_owned(),
226 status: selection.status.as_str().to_owned(),
227 reason: selection.reason.clone(),
228 action: selection.action.clone(),
229 destinations: projection
230 .candidates
231 .iter()
232 .filter(|candidate| candidate.capability == selection.id)
233 .map(|candidate| candidate.destination.clone())
234 .collect(),
235 }
236 }
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub enum OutputSource {
242 Flag,
244 Environment,
246 StateRoot,
248}
249
250impl OutputSource {
251 #[must_use]
253 pub const fn as_str(self) -> &'static str {
254 match self {
255 Self::Flag => "--output",
256 Self::Environment => "RK_STAGE_ROOT",
257 Self::StateRoot => "state root",
258 }
259 }
260}
261
262#[must_use]
268pub fn target_key(canonical_target: &Path) -> String {
269 Digest::of(canonical_target.display().to_string().as_bytes()).to_string()
270}
271
272pub fn resolve_output(
281 flag: Option<&Utf8Path>,
282 canonical_target: &Path,
283) -> Result<(PathBuf, OutputSource), RkError> {
284 if let Some(flag) = flag {
285 let path = if flag.is_absolute() {
286 flag.as_std_path().to_path_buf()
287 } else {
288 std::env::current_dir()?.join(flag.as_std_path())
289 };
290 return Ok((path, OutputSource::Flag));
291 }
292 let leaf = Path::new(&target_key(canonical_target)).join(env!("CARGO_PKG_VERSION"));
293 if let Some(base) = std::env::var_os(OUTPUT_ROOT_VAR).filter(|value| !value.is_empty()) {
294 let base = PathBuf::from(base);
295 let base = if base.is_absolute() {
296 base
297 } else {
298 std::env::current_dir()?.join(base)
299 };
300 return Ok((base.join(leaf), OutputSource::Environment));
301 }
302 let Some(root) = applog::state_root() else {
303 return Err(RkError::refusal(
304 Diagnostic::new(
305 Reason::PrerequisiteUnmet,
306 "no state root resolves, so the stage has nowhere to go, and nothing was written",
307 )
308 .expected("--output <dir>, RK_STAGE_ROOT, or a state root under XDG_STATE_HOME or HOME")
309 .action("pass --output <dir>, or set XDG_STATE_HOME or HOME, and run it again")
310 .target_state("unchanged"),
311 ));
312 };
313 Ok((root.join(STAGES_DIR).join(leaf), OutputSource::StateRoot))
314}
315
316#[derive(Debug)]
320pub struct Prepared {
321 parent: PathBuf,
322 name: std::ffi::OsString,
323 resolved: PathBuf,
324 owner_only: bool,
325}
326
327impl Prepared {
328 #[must_use]
330 pub fn resolved(&self) -> &Path {
331 &self.resolved
332 }
333}
334
335fn eventual(output: &Path) -> Result<PathBuf, RkError> {
345 let mut existing = output;
346 let mut rest: Vec<&std::ffi::OsStr> = Vec::new();
347 loop {
348 match fs::symlink_metadata(existing) {
349 Ok(_) => break,
350 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
351 Err(error) => return Err(error.into()),
352 }
353 let Some(name) = existing.file_name() else {
354 break;
355 };
356 rest.push(name);
357 existing = existing.parent().unwrap_or_else(|| Path::new("/"));
358 }
359 let mut path = fs::canonicalize(if existing.as_os_str().is_empty() {
360 Path::new(".")
361 } else {
362 existing
363 })?;
364 for name in rest.into_iter().rev() {
365 if name == ".." {
366 return Err(RkError::refusal(
367 Diagnostic::new(
368 Reason::Usage,
369 format!(
370 "{} climbs through a directory that does not exist yet, and nothing was written",
371 output.display()
372 ),
373 )
374 .expected("an output path whose absent components are plain names")
375 .target_state("unchanged"),
376 ));
377 }
378 if name != "." {
379 path.push(name);
380 }
381 }
382 Ok(path)
383}
384
385pub fn prepare(
404 output: &Path,
405 source: OutputSource,
406 canonical_target: &Path,
407) -> Result<Prepared, RkError> {
408 let name = output
409 .file_name()
410 .filter(|name| *name != "." && *name != "..")
411 .ok_or_else(|| {
412 RkError::refusal(
413 Diagnostic::new(
414 Reason::Usage,
415 format!("{} names no directory to stage into", output.display()),
416 )
417 .expected("an output path ending in a directory name")
418 .target_state("unchanged"),
419 )
420 })?
421 .to_owned();
422 let eventual = eventual(output)?;
423 if eventual.starts_with(canonical_target) {
424 return Err(RkError::refusal(
425 Diagnostic::new(
426 Reason::DestructiveRefusal,
427 format!(
428 "the stage would stand at {}, inside the target {}, and nothing was written",
429 eventual.display(),
430 canonical_target.display()
431 ),
432 )
433 .expected("a stage root outside the target repository")
434 .action(match source {
435 OutputSource::Flag => "pass an --output outside the target".to_owned(),
436 OutputSource::Environment => {
437 format!("point {OUTPUT_ROOT_VAR} outside the target, or pass --output")
438 }
439 OutputSource::StateRoot => {
440 "move the state root outside the target, or pass --output".to_owned()
441 }
442 })
443 .target_state("unchanged"),
444 ));
445 }
446 let parent = output
447 .parent()
448 .filter(|parent| !parent.as_os_str().is_empty())
449 .map_or_else(|| PathBuf::from("/"), Path::to_path_buf);
450 let owner_only = source == OutputSource::StateRoot;
451 if owner_only {
452 create_owner_only(&parent)?;
453 } else {
454 fs::create_dir_all(&parent)?;
455 }
456 let parent = fs::canonicalize(&parent)?;
457 let resolved = parent.join(&name);
458 match fs::symlink_metadata(&resolved) {
459 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
460 Err(error) => return Err(error.into()),
461 Ok(metadata) if metadata.is_dir() && fs::read_dir(&resolved)?.next().is_none() => {}
462 Ok(_) => {
463 return Err(RkError::refusal(
464 Diagnostic::new(
465 Reason::StateDrift,
466 format!(
467 "{} already exists and is not empty, and nothing was written",
468 resolved.display()
469 ),
470 )
471 .expected("an absent or empty output directory")
472 .action(format!(
473 "rk stage clean {} removes a stage that stands there; otherwise pass another --output",
474 resolved.display()
475 ))
476 .target_state("unchanged"),
477 ));
478 }
479 }
480 Ok(Prepared {
481 parent,
482 name,
483 resolved,
484 owner_only,
485 })
486}
487
488fn create_owner_only(dir: &Path) -> std::io::Result<()> {
491 fs::DirBuilder::new()
492 .recursive(true)
493 .mode(0o700)
494 .create(dir)?;
495 let Some(state_root) = applog::state_root() else {
498 return Ok(());
499 };
500 let base = state_root.join(STAGES_DIR);
501 let Ok(rest) = dir.strip_prefix(&base) else {
502 return Ok(());
503 };
504 let mut current = base;
505 restrict(¤t)?;
506 for component in rest {
507 current.push(component);
508 restrict(¤t)?;
509 }
510 Ok(())
511}
512
513fn restrict(dir: &Path) -> std::io::Result<()> {
516 let metadata = fs::symlink_metadata(dir)?;
517 if metadata.file_type().is_symlink() || !metadata.is_dir() {
518 return Err(std::io::Error::new(
519 std::io::ErrorKind::InvalidData,
520 format!("stage base is not a directory: {}", dir.display()),
521 ));
522 }
523 fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
524}
525
526#[derive(Debug)]
529pub struct Composed {
530 pub files: Vec<(String, Cow<'static, [u8]>)>,
532 pub receipt: Receipt,
534}
535
536#[must_use]
542pub fn compose(
543 projection: &Projection,
544 params: &Params,
545 canonical_target: &Path,
546 stage_root: &Path,
547 record: Option<&Manifest>,
548 dropped: &[crate::landing::apply::Decision],
549 receipt_schema_version: Option<u64>,
550) -> Composed {
551 let mut files: Vec<(String, Cow<'static, [u8]>)> = Vec::new();
552 let mut candidates = Vec::new();
553 for candidate in &projection.candidates {
554 files.push((
555 format!("{ARTIFACTS_DIR}/{}", candidate.destination),
556 Cow::Owned(candidate.bytes.clone()),
557 ));
558 candidates.push(CandidateEntry {
559 destination: candidate.destination.clone(),
560 kind: candidate.kind,
561 placement: match candidate.placement {
562 Placement::Whole => "whole",
563 Placement::Region { .. } => "region",
564 }
565 .to_owned(),
566 sha256: Digest::of(&candidate.bytes),
567 region_sha256: candidate.region.as_deref().map(Digest::of),
568 sources: candidate.sources.clone(),
569 });
570 }
571 for (path, bytes) in reference_files() {
572 files.push((format!("{REFERENCE_DIR}/{path}"), Cow::Borrowed(bytes)));
573 }
574 files.sort_by(|a, b| a.0.cmp(&b.0));
575 let named = |action: crate::landing::apply::Action| -> Vec<String> {
578 dropped
579 .iter()
580 .filter(|decision| decision.action == action)
581 .map(|decision| decision.destination.clone())
582 .collect()
583 };
584 let retired = named(crate::landing::apply::Action::Retired);
585 let dropped = named(crate::landing::apply::Action::Dropped);
586 let mut seeded_present = Vec::new();
587 let mut state_present = Vec::new();
588 if let Some(record) = record {
589 for file in &record.files {
590 let present = fs::symlink_metadata(canonical_target.join(&file.destination)).is_ok();
591 match file.kind {
592 Kind::Seeded if present => seeded_present.push(file.destination.clone()),
593 Kind::State if present => state_present.push(file.destination.clone()),
594 Kind::Rendered | Kind::Seeded | Kind::State => {}
595 }
596 }
597 }
598 let secrets_moved = moved_secrets(projection, canonical_target);
599 let receipt = Receipt {
600 schema: STAGE_SCHEMA.to_owned(),
601 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
602 target: canonical_target.display().to_string(),
603 stage_root: stage_root.display().to_string(),
604 parameters: Parameters::from(params),
605 capabilities: projection
606 .capabilities
607 .iter()
608 .map(|selection| CapabilityNote::of(selection, projection))
609 .collect(),
610 receipt_schema_version,
611 candidates,
612 omissions: projection
613 .omissions
614 .iter()
615 .map(|omission| Note {
616 destination: omission.destination.clone(),
617 reason: omission.reason.clone(),
618 action: omission.action.clone(),
619 })
620 .collect(),
621 collisions: projection
622 .collisions
623 .iter()
624 .map(|collision| Note {
625 action: None,
626 destination: collision.destination.clone(),
627 reason: collision.reason.clone(),
628 })
629 .collect(),
630 retired,
631 dropped,
632 seeded_present,
633 state_present,
634 secrets_moved,
635 reference: REFERENCE_ROOTS
636 .iter()
637 .map(|root| (*root).to_owned())
638 .collect(),
639 };
640 Composed { files, receipt }
641}
642
643fn moved_secrets(
649 projection: &Projection,
650 canonical_target: &Path,
651) -> Vec<crate::landing::apply::SecretMove> {
652 projection
653 .candidates
654 .iter()
655 .filter(|candidate| candidate.destination.starts_with(".github/workflows/"))
656 .filter_map(|candidate| {
657 let current = fs::read(canonical_target.join(&candidate.destination)).ok()?;
658 (current != candidate.bytes).then(|| {
659 crate::landing::apply::secrets_moved(
660 &candidate.destination,
661 &String::from_utf8_lossy(¤t),
662 &String::from_utf8_lossy(&candidate.bytes),
663 )
664 })
665 })
666 .flatten()
667 .collect()
668}
669
670#[must_use]
678pub fn reference_files() -> Vec<(String, &'static [u8])> {
679 let mut out: Vec<(String, &'static [u8])> =
680 vec![("CHANGELOG.md".to_owned(), embedded::CHANGELOG.as_bytes())];
681 for (root, dir) in [
682 ("guidance", &embedded::GUIDANCE),
683 ("method", &embedded::METHOD),
684 ("bindings", &embedded::BINDINGS),
685 ("runbooks", &embedded::RUNBOOKS),
686 ("forges", &embedded::FORGES),
687 ] {
688 for (path, bytes) in embedded::walk(dir) {
689 out.push((format!("{root}/{path}"), bytes));
690 }
691 }
692 for package in skills::packages().unwrap_or_default() {
695 for file in package.files {
696 out.push((format!("skills/{}/{}", package.name, file.path), file.bytes));
697 }
698 }
699 out
700}
701
702const TEMP_ATTEMPTS: u32 = 8;
704
705pub const PAUSE_BEFORE_CLEANUP_VAR: &str = "RK_STAGE_PAUSE_BEFORE_CLEANUP";
709
710const QUARANTINE_PREFIX: &str = ".rk-stage-quarantine-";
712
713const CLAIM_PREFIX: &str = ".rk-stage-claim-";
716
717pub const PAUSE_BEFORE_LAND_VAR: &str = "RK_STAGE_PAUSE_BEFORE_LAND";
721
722pub const PAUSE_AFTER_LAND_VAR: &str = "RK_STAGE_PAUSE_AFTER_LAND";
728
729fn temp_name(name: &std::ffi::OsStr, attempt: u32) -> std::ffi::OsString {
733 let mut out = std::ffi::OsString::from(format!(".rk-stage-{}", std::process::id()));
734 if attempt > 0 {
735 out.push(format!("-{:08x}", crate::held::nonce() & 0xffff_ffff));
736 }
737 out.push(".");
738 out.push(name);
739 out
740}
741
742struct Temp {
746 name: std::ffi::OsString,
747 dir: File,
748 identity: crate::held::Identity,
749}
750
751fn create_temp(prepared: &Prepared, parent: &File) -> std::io::Result<Temp> {
755 let mut builder = fs::DirBuilder::new();
756 if prepared.owner_only {
757 builder.mode(0o700);
758 }
759 let base = crate::held::proc_path(parent);
760 for attempt in 0..TEMP_ATTEMPTS {
761 let name = temp_name(&prepared.name, attempt);
762 match builder.create(base.join(&name)) {
763 Ok(()) => {
764 let dir = crate::held::open_dir(&base.join(&name))?;
765 let identity = crate::held::Identity::of(&dir.metadata()?);
766 return Ok(Temp {
767 name,
768 dir,
769 identity,
770 });
771 }
772 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
773 Err(error) => return Err(error),
774 }
775 }
776 Err(std::io::Error::new(
777 std::io::ErrorKind::AlreadyExists,
778 format!(
779 "every sibling name for the stage below {} is taken, and nothing was written or removed",
780 prepared.parent.display()
781 ),
782 ))
783}
784
785pub fn write(prepared: &Prepared, composed: &Composed) -> Result<(), RkError> {
801 let stop = std::env::var_os(INTERRUPT_VAR).map(PathBuf::from);
802 write_stopping_at(prepared, composed, stop.as_deref())
803}
804
805pub fn write_stopping_at(
812 prepared: &Prepared,
813 composed: &Composed,
814 stop: Option<&Path>,
815) -> Result<(), RkError> {
816 let parent = crate::held::open_dir(&prepared.parent)?;
817 let temp = create_temp(prepared, &parent)?;
818 if let Err(error) = write_into(&temp, composed, stop) {
819 return Err(RkError::Io(cleanup(
820 &parent,
821 &temp.name,
822 temp.identity,
823 error,
824 )));
825 }
826 land(&parent, &temp, prepared).map_err(RkError::Io)
827}
828
829fn land(parent: &File, temp: &Temp, prepared: &Prepared) -> std::io::Result<()> {
843 crate::held::pause(PAUSE_BEFORE_LAND_VAR, "finished", "proceed");
844 let base = crate::held::proc_path(parent);
845 let (claim, current) = crate::held::quarantine(parent, &temp.name, CLAIM_PREFIX)?;
846 if current.file_type().is_symlink() || crate::held::Identity::of(¤t) != temp.identity {
847 return Err(std::io::Error::other(format!(
848 "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",
849 prepared.parent.join(&temp.name).display(),
850 claim.display()
851 )));
852 }
853 if let Err(error) = fs::rename(base.join(&claim), base.join(&prepared.name)) {
854 return Err(cleanup(parent, &claim, temp.identity, error));
855 }
856 crate::held::pause(PAUSE_AFTER_LAND_VAR, "landed", "proceed");
857 let public = fs::metadata(&prepared.parent)
858 .ok()
859 .map(|metadata| crate::held::Identity::of(&metadata));
860 let held_parent = crate::held::Identity::of(&parent.metadata()?);
861 if public == Some(held_parent) {
862 return Ok(());
863 }
864 Err(cleanup(
865 parent,
866 &prepared.name,
867 temp.identity,
868 std::io::Error::other(format!(
869 "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",
870 prepared.parent.display()
871 )),
872 ))
873}
874
875fn cleanup(
879 parent: &File,
880 name: &std::ffi::OsStr,
881 identity: crate::held::Identity,
882 error: std::io::Error,
883) -> std::io::Error {
884 crate::held::pause(PAUSE_BEFORE_CLEANUP_VAR, "stopped", "proceed");
885 let base = crate::held::proc_path(parent);
886 let (quarantined, current) = match crate::held::quarantine(parent, name, QUARANTINE_PREFIX) {
887 Ok(moved) => moved,
888 Err(quarantine) => {
889 return std::io::Error::new(
890 error.kind(),
891 format!(
892 "{error}; the entry under {} could not be quarantined and was left in place: {quarantine}",
893 name.display()
894 ),
895 );
896 }
897 };
898 if current.file_type().is_symlink() || crate::held::Identity::of(¤t) != identity {
899 return std::io::Error::new(
900 error.kind(),
901 format!(
902 "{error}; the entry under {} was not the directory this run created, so it was moved to {} and left in place",
903 name.display(),
904 quarantined.display()
905 ),
906 );
907 }
908 match fs::remove_dir_all(base.join(&quarantined)) {
909 Ok(()) => error,
910 Err(removal) => std::io::Error::new(
911 error.kind(),
912 format!(
913 "{error}; the directory was moved to {} and could not be removed: {removal}",
914 quarantined.display()
915 ),
916 ),
917 }
918}
919
920fn write_into(temp: &Temp, composed: &Composed, stop: Option<&Path>) -> std::io::Result<()> {
923 let base = crate::held::proc_path(&temp.dir);
924 for (path, bytes) in &composed.files {
925 let destination = base.join(path);
926 if let Some(parent) = destination.parent() {
927 fs::create_dir_all(parent)?;
928 }
929 fs::write(&destination, bytes)?;
930 if stop.is_some_and(|stop| Path::new(path) == stop) {
931 return Err(std::io::Error::other(format!(
932 "the stage was stopped after {path} for the proof"
933 )));
934 }
935 }
936 let text = serde_json::to_string_pretty(&composed.receipt).map_err(std::io::Error::other)?;
937 let mut receipt = fs::OpenOptions::new()
938 .write(true)
939 .create_new(true)
940 .mode(0o600)
941 .open(base.join(RECEIPT_NAME))?;
942 receipt.write_all(text.as_bytes())?;
943 receipt.write_all(b"\n")?;
944 receipt.sync_all()?;
945 Ok(())
946}
947
948pub fn purpose(
960 target: &Utf8Path,
961 config: Option<&crate::config::Config>,
962 recorded: bool,
963) -> Result<crate::landing::Purpose, RkError> {
964 use crate::assess::{Standing, Unrecorded};
965 Ok(if recorded {
966 crate::landing::Purpose::Upgrade
967 } else if crate::assess::standing(target, config)? == Standing::Unrecorded(Unrecorded::Adopt) {
968 crate::landing::Purpose::Adopt
969 } else {
970 crate::landing::Purpose::Init
971 })
972}
973
974#[must_use]
978pub fn recorded_schema_version(target: &Utf8Path) -> Option<u64> {
979 let bytes = fs::read(target.join(crate::landing::manifest::MANIFEST_PATH)).ok()?;
980 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
981 value.get("schema_version")?.as_u64()
982}
983
984#[cfg(test)]
985mod tests {
986 use super::{
987 CandidateEntry, Note, Parameters, REFERENCE_ROOTS, Receipt, STAGE_SCHEMA, reference_files,
988 target_key,
989 };
990 use crate::digest::Digest;
991 use crate::landing::Integration;
992 use crate::landing::Kind;
993 use crate::landing::manifest::{CheckoutMode, Style};
994 use crate::profile::{
995 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
996 };
997
998 fn test_parameters() -> Parameters {
1001 Parameters {
1002 profile: ProfileSnapshot {
1003 technologies: vec!["rust".into()],
1004 forge: Some("github".into()),
1005 release: ReleaseIntent {
1006 mode: ReleaseMode::Automatic,
1007 driver: Some("rust".into()),
1008 style: Some(Style::Trunk),
1009 line_prefix: Some("release/".into()),
1010 registry: None,
1011 build: None,
1012 },
1013 },
1014 git: GitWorkflow {
1015 trunk: "master".into(),
1016 checkout_mode: CheckoutMode::LinkedWorktree,
1017 integration: Integration::Local,
1018 merge_form: crate::landing::MergeForm::Squash,
1019 },
1020 capabilities: CapabilityRequests {
1021 nix_packaging: false,
1022 reporting_policy: true,
1023 scorecard: false,
1024 code_scanning: None,
1025 workflow_guards: Some(false),
1026 },
1027 repo: "acme/widget".into(),
1028 security_contact: String::new(),
1029 required_check: String::new(),
1030 dev_environment: None,
1031 security_response: "best-effort".into(),
1032 security_scope: String::new(),
1033 }
1034 }
1035
1036 #[test]
1039 fn the_stage_receipt_schema_snapshot_holds() {
1040 let receipt = Receipt {
1041 schema: STAGE_SCHEMA.to_owned(),
1042 rk_version: "0.0.0".into(),
1043 target: "/tmp/t".into(),
1044 stage_root: "/tmp/s".into(),
1045 parameters: test_parameters(),
1046 capabilities: vec![],
1047 receipt_schema_version: Some(6),
1048 candidates: vec![
1049 CandidateEntry {
1050 destination: "AGENTS.md".into(),
1051 kind: Kind::Rendered,
1052 placement: "region".into(),
1053 sha256: Digest::of(b"a"),
1054 region_sha256: Some(Digest::of(b"r")),
1055 sources: vec!["blocks/routing.md.in".into()],
1056 },
1057 CandidateEntry {
1058 destination: "release-plz.toml".into(),
1059 kind: Kind::Seeded,
1060 placement: "whole".into(),
1061 sha256: Digest::of(b"b"),
1062 region_sha256: None,
1063 sources: vec!["snippets/rust/github/release-plz.toml".into()],
1064 },
1065 ],
1066 omissions: vec![Note {
1067 destination: "flake.nix".into(),
1068 reason: "the target already carries flake.nix".into(),
1069 action: None,
1070 }],
1071 collisions: vec![],
1072 retired: vec!["old.yml".into()],
1073 dropped: vec!["edited.yml".into()],
1074 seeded_present: vec!["release-plz.toml".into()],
1075 state_present: vec![],
1076 secrets_moved: Vec::new(),
1077 reference: REFERENCE_ROOTS
1078 .iter()
1079 .map(|root| (*root).to_owned())
1080 .collect(),
1081 };
1082 assert_eq!(
1083 serde_json::to_string(&receipt).expect("a receipt serializes"),
1084 format!(
1085 r#"{{"schema":"rk.stage/9","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":""}},"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"]}}"#,
1086 Digest::of(b"a"),
1087 Digest::of(b"r"),
1088 Digest::of(b"b")
1089 )
1090 );
1091 let back: Receipt =
1092 serde_json::from_str(&serde_json::to_string(&receipt).expect("serializes"))
1093 .expect("a receipt reads back");
1094 assert_eq!(back.stage_root, "/tmp/s");
1095 }
1096
1097 #[test]
1100 fn every_reference_root_serves_a_file_and_nothing_else_is_served() {
1101 let files = reference_files();
1102 for root in REFERENCE_ROOTS {
1103 assert!(
1104 files
1105 .iter()
1106 .any(|(path, _)| path == root || path.starts_with(&format!("{root}/"))),
1107 "{root}: the reference tree carries no file for it"
1108 );
1109 }
1110 for (path, _) in &files {
1111 assert!(
1112 REFERENCE_ROOTS
1113 .iter()
1114 .any(|root| path == root || path.starts_with(&format!("{root}/"))),
1115 "{path}: outside every declared reference root"
1116 );
1117 assert!(
1118 !path
1119 .split('/')
1120 .any(|part| part == "_docs" || part == "tests" || part == "src"),
1121 "{path}: an instance-owned or source path in the reference tree"
1122 );
1123 }
1124 }
1125
1126 #[test]
1130 fn a_pre_existing_temp_sibling_is_never_touched() {
1131 let scratch = tempfile::tempdir().expect("a scratch dir exists");
1132 let parent = std::fs::canonicalize(scratch.path()).expect("canonical");
1133 let output = parent.join("stage");
1134 let target = parent.join("target");
1135 std::fs::create_dir(&target).expect("creates");
1136 let prepared =
1137 super::prepare(&output, super::OutputSource::Flag, &target).expect("prepares");
1138 let stranger = parent.join(super::temp_name(std::ffi::OsStr::new("stage"), 0));
1139 std::fs::create_dir_all(stranger.join("deep")).expect("creates");
1140 std::fs::write(stranger.join("deep/canary"), b"not yours").expect("writes");
1141 std::fs::write(stranger.join("canary"), b"still not yours").expect("writes");
1142 let composed = super::Composed {
1143 files: vec![(
1144 "artifacts/a.txt".to_owned(),
1145 std::borrow::Cow::Borrowed(b"a"),
1146 )],
1147 receipt: sample_receipt(),
1148 };
1149 super::write(&prepared, &composed).expect("the write lands beside the stranger");
1150 assert_eq!(
1151 std::fs::read(output.join("artifacts/a.txt")).expect("reads"),
1152 b"a"
1153 );
1154 assert_eq!(
1155 std::fs::read(stranger.join("deep/canary")).expect("the stranger reads"),
1156 b"not yours"
1157 );
1158 assert_eq!(
1159 std::fs::read(stranger.join("canary")).expect("the stranger reads"),
1160 b"still not yours"
1161 );
1162 let output_two = parent.join("stage-two");
1164 let prepared =
1165 super::prepare(&output_two, super::OutputSource::Flag, &target).expect("prepares");
1166 let stranger_two = parent.join(super::temp_name(std::ffi::OsStr::new("stage-two"), 0));
1167 std::fs::create_dir(&stranger_two).expect("creates");
1168 std::fs::write(stranger_two.join("canary"), b"kept").expect("writes");
1169 let stopped = super::write_stopping_at(
1170 &prepared,
1171 &composed,
1172 Some(std::path::Path::new("artifacts/a.txt")),
1173 );
1174 assert!(stopped.is_err());
1175 assert!(!output_two.exists());
1176 assert_eq!(
1177 std::fs::read(stranger_two.join("canary")).expect("the stranger reads"),
1178 b"kept"
1179 );
1180 let leftovers: Vec<String> = std::fs::read_dir(&parent)
1181 .expect("reads")
1182 .map(|entry| {
1183 entry
1184 .expect("an entry")
1185 .file_name()
1186 .to_string_lossy()
1187 .into_owned()
1188 })
1189 .filter(|name| name.starts_with(".rk-stage-"))
1190 .collect();
1191 assert_eq!(
1192 leftovers.len(),
1193 2,
1194 "only the two strangers remain: {leftovers:?}"
1195 );
1196 }
1197
1198 #[test]
1201 fn a_stage_root_inside_the_target_refuses_before_anything_is_created() {
1202 let scratch = tempfile::tempdir().expect("a scratch dir exists");
1203 let target = std::fs::canonicalize(scratch.path())
1204 .expect("canonical")
1205 .join("t");
1206 std::fs::create_dir(&target).expect("creates");
1207 for (output, source) in [
1208 (target.join("stage"), super::OutputSource::Flag),
1209 (
1210 target.join("deep/er/stage"),
1211 super::OutputSource::Environment,
1212 ),
1213 (
1214 target.join("state/release-kit/stages/k/v"),
1215 super::OutputSource::StateRoot,
1216 ),
1217 (target.clone(), super::OutputSource::Flag),
1218 ] {
1219 let error = super::prepare(&output, source, &target).expect_err("refuses");
1220 assert_eq!(
1221 error.reason(),
1222 crate::diagnostic::Reason::DestructiveRefusal
1223 );
1224 assert_eq!(error.exit_code(), 73);
1225 }
1226 assert_eq!(
1227 std::fs::read_dir(&target).expect("reads").count(),
1228 0,
1229 "a refusal created a component inside the target"
1230 );
1231 }
1232
1233 fn sample_receipt() -> Receipt {
1234 Receipt {
1235 schema: STAGE_SCHEMA.to_owned(),
1236 rk_version: "0.0.0".into(),
1237 target: "/tmp/t".into(),
1238 stage_root: "/tmp/s".into(),
1239 parameters: test_parameters(),
1240 capabilities: vec![],
1241 receipt_schema_version: None,
1242 candidates: vec![],
1243 omissions: vec![],
1244 collisions: vec![],
1245 retired: vec![],
1246 dropped: vec![],
1247 seeded_present: vec![],
1248 state_present: vec![],
1249 secrets_moved: Vec::new(),
1250 reference: vec![],
1251 }
1252 }
1253
1254 #[test]
1257 fn the_target_key_is_one_flat_digest() {
1258 let key = target_key(std::path::Path::new("/a/b"));
1259 assert_eq!(key.len(), 64);
1260 assert!(key.bytes().all(|b| b.is_ascii_hexdigit()));
1261 assert_ne!(key, target_key(std::path::Path::new("/a/c")));
1262 }
1263}