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/8";
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)]
145 pub required_check: String,
146 #[serde(default)]
149 pub required_workflow: String,
150 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct CandidateEntry {
176 pub destination: String,
178 pub kind: Kind,
180 pub placement: String,
183 pub sha256: Digest,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub region_sha256: Option<Digest>,
188 pub sources: Vec<String>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct Note {
195 pub destination: String,
197 pub reason: String,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub action: Option<String>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct CapabilityNote {
208 pub id: String,
210 pub status: String,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub reason: Option<String>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub action: Option<String>,
219 pub destinations: Vec<String>,
221}
222
223impl CapabilityNote {
224 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum OutputSource {
246 Flag,
248 Environment,
250 StateRoot,
252}
253
254impl OutputSource {
255 #[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#[must_use]
272pub fn target_key(canonical_target: &Path) -> String {
273 Digest::of(canonical_target.display().to_string().as_bytes()).to_string()
274}
275
276pub 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#[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 #[must_use]
334 pub fn resolved(&self) -> &Path {
335 &self.resolved
336 }
337}
338
339fn 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
389pub 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
492fn create_owner_only(dir: &Path) -> std::io::Result<()> {
495 fs::DirBuilder::new()
496 .recursive(true)
497 .mode(0o700)
498 .create(dir)?;
499 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(¤t)?;
510 for component in rest {
511 current.push(component);
512 restrict(¤t)?;
513 }
514 Ok(())
515}
516
517fn 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#[derive(Debug)]
533pub struct Composed {
534 pub files: Vec<(String, Cow<'static, [u8]>)>,
536 pub receipt: Receipt,
538}
539
540#[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 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
647fn 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(¤t),
666 &String::from_utf8_lossy(&candidate.bytes),
667 )
668 })
669 })
670 .flatten()
671 .collect()
672}
673
674#[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 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
706const TEMP_ATTEMPTS: u32 = 8;
708
709pub const PAUSE_BEFORE_CLEANUP_VAR: &str = "RK_STAGE_PAUSE_BEFORE_CLEANUP";
713
714const QUARANTINE_PREFIX: &str = ".rk-stage-quarantine-";
716
717const CLAIM_PREFIX: &str = ".rk-stage-claim-";
720
721pub const PAUSE_BEFORE_LAND_VAR: &str = "RK_STAGE_PAUSE_BEFORE_LAND";
725
726pub const PAUSE_AFTER_LAND_VAR: &str = "RK_STAGE_PAUSE_AFTER_LAND";
732
733fn 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
746struct Temp {
750 name: std::ffi::OsString,
751 dir: File,
752 identity: crate::held::Identity,
753}
754
755fn 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
789pub 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
809pub 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
833fn 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(¤t) != 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
879fn 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(¤t) != 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
924fn 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
952pub 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#[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 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 #[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 #[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 #[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 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 #[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 #[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}