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