1pub mod clean;
19
20use std::borrow::Cow;
21use std::fs::{self, File};
22use std::io::Write as _;
23use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _};
24use std::path::{Path, PathBuf};
25
26use camino::Utf8Path;
27use serde::{Deserialize, Serialize};
28
29use crate::applog;
30use crate::diagnostic::{Diagnostic, Reason};
31use crate::digest::Digest;
32use crate::embedded;
33use crate::error::RkError;
34use crate::landing::manifest::Manifest;
35use crate::landing::{Kind, Params};
36use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot};
37use crate::projection::{Placement, Projection};
38use crate::skills;
39
40pub const STAGE_SCHEMA: &str = "rk.stage/4";
42
43pub const RECEIPT_NAME: &str = "stage.json";
45
46pub const OUTPUT_ROOT_VAR: &str = "RK_STAGE_ROOT";
48
49pub const STAGES_DIR: &str = "stages";
51
52pub const ARTIFACTS_DIR: &str = "artifacts";
54
55pub const REFERENCE_DIR: &str = "reference";
57
58pub const SETUP_SKILL: &str = "rk-setup";
61
62pub const INTERRUPT_VAR: &str = "RK_STAGE_INTERRUPT_AT";
65
66pub const REFERENCE_ROOTS: [&str; 8] = [
69 "CHANGELOG.md",
70 "guidance",
71 "method",
72 "bindings",
73 "runbooks",
74 "forges",
75 "skills/rk-setup",
76 "skill-shared",
77];
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Receipt {
83 pub schema: String,
85 pub rk_version: String,
87 pub target: String,
89 pub stage_root: String,
91 pub parameters: Parameters,
93 pub capabilities: Vec<CapabilityNote>,
95 pub receipt_schema_version: Option<u64>,
98 pub candidates: Vec<CandidateEntry>,
100 pub omissions: Vec<Note>,
102 pub collisions: Vec<Note>,
104 pub retired: Vec<String>,
107 pub seeded_present: Vec<String>,
110 pub state_present: Vec<String>,
113 pub reference: Vec<String>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct Parameters {
120 pub profile: ProfileSnapshot,
122 pub git: GitWorkflow,
124 pub capabilities: CapabilityRequests,
126 pub repo: String,
128 pub security_contact: String,
130 pub security_response: String,
132}
133
134impl From<&Params> for Parameters {
135 fn from(params: &Params) -> Self {
136 Self {
137 profile: params.profile().clone(),
138 git: params.git().clone(),
139 capabilities: params.capabilities().clone(),
140 repo: params.repo().to_owned(),
141 security_contact: params.security_contact().to_owned(),
142 security_response: params.security_response().to_owned(),
143 }
144 }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct CandidateEntry {
150 pub destination: String,
152 pub kind: Kind,
154 pub placement: String,
157 pub sha256: Digest,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub region_sha256: Option<Digest>,
162 pub sources: Vec<String>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct Note {
169 pub destination: String,
171 pub reason: String,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub action: Option<String>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct CapabilityNote {
182 pub id: String,
184 pub status: String,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub reason: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub action: Option<String>,
193 pub destinations: Vec<String>,
195}
196
197impl CapabilityNote {
198 #[must_use]
201 pub fn of(selection: &crate::profile::catalog::Selection, projection: &Projection) -> Self {
202 Self {
203 id: selection.id.to_owned(),
204 status: selection.status.as_str().to_owned(),
205 reason: selection.reason.clone(),
206 action: selection.action.clone(),
207 destinations: projection
208 .candidates
209 .iter()
210 .filter(|candidate| candidate.capability == selection.id)
211 .map(|candidate| candidate.destination.clone())
212 .collect(),
213 }
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum OutputSource {
220 Flag,
222 Environment,
224 StateRoot,
226}
227
228impl OutputSource {
229 #[must_use]
231 pub const fn as_str(self) -> &'static str {
232 match self {
233 Self::Flag => "--output",
234 Self::Environment => "RK_STAGE_ROOT",
235 Self::StateRoot => "state root",
236 }
237 }
238}
239
240#[must_use]
246pub fn target_key(canonical_target: &Path) -> String {
247 Digest::of(canonical_target.display().to_string().as_bytes()).to_string()
248}
249
250pub fn resolve_output(
259 flag: Option<&Utf8Path>,
260 canonical_target: &Path,
261) -> Result<(PathBuf, OutputSource), RkError> {
262 if let Some(flag) = flag {
263 let path = if flag.is_absolute() {
264 flag.as_std_path().to_path_buf()
265 } else {
266 std::env::current_dir()?.join(flag.as_std_path())
267 };
268 return Ok((path, OutputSource::Flag));
269 }
270 let leaf = Path::new(&target_key(canonical_target)).join(env!("CARGO_PKG_VERSION"));
271 if let Some(base) = std::env::var_os(OUTPUT_ROOT_VAR).filter(|value| !value.is_empty()) {
272 let base = PathBuf::from(base);
273 let base = if base.is_absolute() {
274 base
275 } else {
276 std::env::current_dir()?.join(base)
277 };
278 return Ok((base.join(leaf), OutputSource::Environment));
279 }
280 let Some(root) = applog::state_root() else {
281 return Err(RkError::refusal(
282 Diagnostic::new(
283 Reason::PrerequisiteUnmet,
284 "no state root resolves, so the stage has nowhere to go, and nothing was written",
285 )
286 .expected("--output <dir>, RK_STAGE_ROOT, or a state root under XDG_STATE_HOME or HOME")
287 .action("pass --output <dir>, or set XDG_STATE_HOME or HOME, and run it again")
288 .target_state("unchanged"),
289 ));
290 };
291 Ok((root.join(STAGES_DIR).join(leaf), OutputSource::StateRoot))
292}
293
294#[derive(Debug)]
298pub struct Prepared {
299 parent: PathBuf,
300 name: std::ffi::OsString,
301 resolved: PathBuf,
302 owner_only: bool,
303}
304
305impl Prepared {
306 #[must_use]
308 pub fn resolved(&self) -> &Path {
309 &self.resolved
310 }
311}
312
313fn eventual(output: &Path) -> Result<PathBuf, RkError> {
323 let mut existing = output;
324 let mut rest: Vec<&std::ffi::OsStr> = Vec::new();
325 loop {
326 match fs::symlink_metadata(existing) {
327 Ok(_) => break,
328 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
329 Err(error) => return Err(error.into()),
330 }
331 let Some(name) = existing.file_name() else {
332 break;
333 };
334 rest.push(name);
335 existing = existing.parent().unwrap_or_else(|| Path::new("/"));
336 }
337 let mut path = fs::canonicalize(if existing.as_os_str().is_empty() {
338 Path::new(".")
339 } else {
340 existing
341 })?;
342 for name in rest.into_iter().rev() {
343 if name == ".." {
344 return Err(RkError::refusal(
345 Diagnostic::new(
346 Reason::Usage,
347 format!(
348 "{} climbs through a directory that does not exist yet, and nothing was written",
349 output.display()
350 ),
351 )
352 .expected("an output path whose absent components are plain names")
353 .target_state("unchanged"),
354 ));
355 }
356 if name != "." {
357 path.push(name);
358 }
359 }
360 Ok(path)
361}
362
363pub fn prepare(
382 output: &Path,
383 source: OutputSource,
384 canonical_target: &Path,
385) -> Result<Prepared, RkError> {
386 let name = output
387 .file_name()
388 .filter(|name| *name != "." && *name != "..")
389 .ok_or_else(|| {
390 RkError::refusal(
391 Diagnostic::new(
392 Reason::Usage,
393 format!("{} names no directory to stage into", output.display()),
394 )
395 .expected("an output path ending in a directory name")
396 .target_state("unchanged"),
397 )
398 })?
399 .to_owned();
400 let eventual = eventual(output)?;
401 if eventual.starts_with(canonical_target) {
402 return Err(RkError::refusal(
403 Diagnostic::new(
404 Reason::DestructiveRefusal,
405 format!(
406 "the stage would stand at {}, inside the target {}, and nothing was written",
407 eventual.display(),
408 canonical_target.display()
409 ),
410 )
411 .expected("a stage root outside the target repository")
412 .action(match source {
413 OutputSource::Flag => "pass an --output outside the target".to_owned(),
414 OutputSource::Environment => {
415 format!("point {OUTPUT_ROOT_VAR} outside the target, or pass --output")
416 }
417 OutputSource::StateRoot => {
418 "move the state root outside the target, or pass --output".to_owned()
419 }
420 })
421 .target_state("unchanged"),
422 ));
423 }
424 let parent = output
425 .parent()
426 .filter(|parent| !parent.as_os_str().is_empty())
427 .map_or_else(|| PathBuf::from("/"), Path::to_path_buf);
428 let owner_only = source == OutputSource::StateRoot;
429 if owner_only {
430 create_owner_only(&parent)?;
431 } else {
432 fs::create_dir_all(&parent)?;
433 }
434 let parent = fs::canonicalize(&parent)?;
435 let resolved = parent.join(&name);
436 match fs::symlink_metadata(&resolved) {
437 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
438 Err(error) => return Err(error.into()),
439 Ok(metadata) if metadata.is_dir() && fs::read_dir(&resolved)?.next().is_none() => {}
440 Ok(_) => {
441 return Err(RkError::refusal(
442 Diagnostic::new(
443 Reason::StateDrift,
444 format!(
445 "{} already exists and is not empty, and nothing was written",
446 resolved.display()
447 ),
448 )
449 .expected("an absent or empty output directory")
450 .action(format!(
451 "rk stage clean {} removes a stage that stands there; otherwise pass another --output",
452 resolved.display()
453 ))
454 .target_state("unchanged"),
455 ));
456 }
457 }
458 Ok(Prepared {
459 parent,
460 name,
461 resolved,
462 owner_only,
463 })
464}
465
466fn create_owner_only(dir: &Path) -> std::io::Result<()> {
469 fs::DirBuilder::new()
470 .recursive(true)
471 .mode(0o700)
472 .create(dir)?;
473 let Some(state_root) = applog::state_root() else {
476 return Ok(());
477 };
478 let base = state_root.join(STAGES_DIR);
479 let Ok(rest) = dir.strip_prefix(&base) else {
480 return Ok(());
481 };
482 let mut current = base;
483 restrict(¤t)?;
484 for component in rest {
485 current.push(component);
486 restrict(¤t)?;
487 }
488 Ok(())
489}
490
491fn restrict(dir: &Path) -> std::io::Result<()> {
494 let metadata = fs::symlink_metadata(dir)?;
495 if metadata.file_type().is_symlink() || !metadata.is_dir() {
496 return Err(std::io::Error::new(
497 std::io::ErrorKind::InvalidData,
498 format!("stage base is not a directory: {}", dir.display()),
499 ));
500 }
501 fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
502}
503
504#[derive(Debug)]
507pub struct Composed {
508 pub files: Vec<(String, Cow<'static, [u8]>)>,
510 pub receipt: Receipt,
512}
513
514#[must_use]
518pub fn compose(
519 projection: &Projection,
520 params: &Params,
521 canonical_target: &Path,
522 stage_root: &Path,
523 record: Option<&Manifest>,
524 receipt_schema_version: Option<u64>,
525) -> Composed {
526 let mut files: Vec<(String, Cow<'static, [u8]>)> = Vec::new();
527 let mut candidates = Vec::new();
528 for candidate in &projection.candidates {
529 files.push((
530 format!("{ARTIFACTS_DIR}/{}", candidate.destination),
531 Cow::Owned(candidate.bytes.clone()),
532 ));
533 candidates.push(CandidateEntry {
534 destination: candidate.destination.clone(),
535 kind: candidate.kind,
536 placement: match candidate.placement {
537 Placement::Whole => "whole",
538 Placement::Region { .. } => "region",
539 }
540 .to_owned(),
541 sha256: Digest::of(&candidate.bytes),
542 region_sha256: candidate.region.as_deref().map(Digest::of),
543 sources: candidate.sources.clone(),
544 });
545 }
546 for (path, bytes) in reference_files() {
547 files.push((format!("{REFERENCE_DIR}/{path}"), Cow::Borrowed(bytes)));
548 }
549 files.sort_by(|a, b| a.0.cmp(&b.0));
550 let produced = |destination: &str| {
551 projection
552 .candidates
553 .iter()
554 .any(|candidate| candidate.destination == destination)
555 || projection
556 .omissions
557 .iter()
558 .any(|omission| omission.destination == destination)
559 };
560 let mut retired = Vec::new();
561 let mut seeded_present = Vec::new();
562 let mut state_present = Vec::new();
563 if let Some(record) = record {
564 for file in &record.files {
565 if !produced(&file.destination) {
566 retired.push(file.destination.clone());
567 }
568 let present = fs::symlink_metadata(canonical_target.join(&file.destination)).is_ok();
569 match file.kind {
570 Kind::Seeded if present => seeded_present.push(file.destination.clone()),
571 Kind::State if present => state_present.push(file.destination.clone()),
572 Kind::Rendered | Kind::Seeded | Kind::State => {}
573 }
574 }
575 }
576 let receipt = Receipt {
577 schema: STAGE_SCHEMA.to_owned(),
578 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
579 target: canonical_target.display().to_string(),
580 stage_root: stage_root.display().to_string(),
581 parameters: Parameters::from(params),
582 capabilities: projection
583 .capabilities
584 .iter()
585 .map(|selection| CapabilityNote::of(selection, projection))
586 .collect(),
587 receipt_schema_version,
588 candidates,
589 omissions: projection
590 .omissions
591 .iter()
592 .map(|omission| Note {
593 destination: omission.destination.clone(),
594 reason: omission.reason.clone(),
595 action: omission.action.clone(),
596 })
597 .collect(),
598 collisions: projection
599 .collisions
600 .iter()
601 .map(|collision| Note {
602 action: None,
603 destination: collision.destination.clone(),
604 reason: collision.reason.clone(),
605 })
606 .collect(),
607 retired,
608 seeded_present,
609 state_present,
610 reference: REFERENCE_ROOTS
611 .iter()
612 .map(|root| (*root).to_owned())
613 .collect(),
614 };
615 Composed { files, receipt }
616}
617
618#[must_use]
626pub fn reference_files() -> Vec<(String, &'static [u8])> {
627 let mut out: Vec<(String, &'static [u8])> =
628 vec![("CHANGELOG.md".to_owned(), embedded::CHANGELOG.as_bytes())];
629 for (root, dir) in [
630 ("guidance", &embedded::GUIDANCE),
631 ("method", &embedded::METHOD),
632 ("bindings", &embedded::BINDINGS),
633 ("runbooks", &embedded::RUNBOOKS),
634 ("forges", &embedded::FORGES),
635 ] {
636 for (path, bytes) in embedded::walk(dir) {
637 out.push((format!("{root}/{path}"), bytes));
638 }
639 }
640 let prefix = format!("{SETUP_SKILL}/");
641 let mut skill_text = String::new();
642 for (path, bytes) in embedded::walk(&embedded::SKILLS) {
643 if path.starts_with(&prefix) {
644 if path == format!("{prefix}SKILL.md") {
645 skill_text = String::from_utf8_lossy(bytes).into_owned();
646 }
647 out.push((format!("skills/{path}"), bytes));
648 }
649 }
650 for artifact in skills::shared() {
651 if skill_text.contains(&artifact.path) {
652 out.push((format!("skill-shared/{}", artifact.path), artifact.bytes));
653 }
654 }
655 out
656}
657
658const TEMP_ATTEMPTS: u32 = 8;
660
661pub const PAUSE_BEFORE_CLEANUP_VAR: &str = "RK_STAGE_PAUSE_BEFORE_CLEANUP";
665
666const QUARANTINE_PREFIX: &str = ".rk-stage-quarantine-";
668
669const CLAIM_PREFIX: &str = ".rk-stage-claim-";
672
673pub const PAUSE_BEFORE_LAND_VAR: &str = "RK_STAGE_PAUSE_BEFORE_LAND";
677
678pub const PAUSE_AFTER_LAND_VAR: &str = "RK_STAGE_PAUSE_AFTER_LAND";
684
685fn temp_name(name: &std::ffi::OsStr, attempt: u32) -> std::ffi::OsString {
689 let mut out = std::ffi::OsString::from(format!(".rk-stage-{}", std::process::id()));
690 if attempt > 0 {
691 out.push(format!("-{:08x}", crate::held::nonce() & 0xffff_ffff));
692 }
693 out.push(".");
694 out.push(name);
695 out
696}
697
698struct Temp {
702 name: std::ffi::OsString,
703 dir: File,
704 identity: crate::held::Identity,
705}
706
707fn create_temp(prepared: &Prepared, parent: &File) -> std::io::Result<Temp> {
711 let mut builder = fs::DirBuilder::new();
712 if prepared.owner_only {
713 builder.mode(0o700);
714 }
715 let base = crate::held::proc_path(parent);
716 for attempt in 0..TEMP_ATTEMPTS {
717 let name = temp_name(&prepared.name, attempt);
718 match builder.create(base.join(&name)) {
719 Ok(()) => {
720 let dir = crate::held::open_dir(&base.join(&name))?;
721 let identity = crate::held::Identity::of(&dir.metadata()?);
722 return Ok(Temp {
723 name,
724 dir,
725 identity,
726 });
727 }
728 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
729 Err(error) => return Err(error),
730 }
731 }
732 Err(std::io::Error::new(
733 std::io::ErrorKind::AlreadyExists,
734 format!(
735 "every sibling name for the stage below {} is taken, and nothing was written or removed",
736 prepared.parent.display()
737 ),
738 ))
739}
740
741pub fn write(prepared: &Prepared, composed: &Composed) -> Result<(), RkError> {
757 let stop = std::env::var_os(INTERRUPT_VAR).map(PathBuf::from);
758 write_stopping_at(prepared, composed, stop.as_deref())
759}
760
761pub fn write_stopping_at(
768 prepared: &Prepared,
769 composed: &Composed,
770 stop: Option<&Path>,
771) -> Result<(), RkError> {
772 let parent = crate::held::open_dir(&prepared.parent)?;
773 let temp = create_temp(prepared, &parent)?;
774 if let Err(error) = write_into(&temp, composed, stop) {
775 return Err(RkError::Io(cleanup(
776 &parent,
777 &temp.name,
778 temp.identity,
779 error,
780 )));
781 }
782 land(&parent, &temp, prepared).map_err(RkError::Io)
783}
784
785fn land(parent: &File, temp: &Temp, prepared: &Prepared) -> std::io::Result<()> {
799 crate::held::pause(PAUSE_BEFORE_LAND_VAR, "finished", "proceed");
800 let base = crate::held::proc_path(parent);
801 let (claim, current) = crate::held::quarantine(parent, &temp.name, CLAIM_PREFIX)?;
802 if current.file_type().is_symlink() || crate::held::Identity::of(¤t) != temp.identity {
803 return Err(std::io::Error::other(format!(
804 "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",
805 prepared.parent.join(&temp.name).display(),
806 claim.display()
807 )));
808 }
809 if let Err(error) = fs::rename(base.join(&claim), base.join(&prepared.name)) {
810 return Err(cleanup(parent, &claim, temp.identity, error));
811 }
812 crate::held::pause(PAUSE_AFTER_LAND_VAR, "landed", "proceed");
813 let public = fs::metadata(&prepared.parent)
814 .ok()
815 .map(|metadata| crate::held::Identity::of(&metadata));
816 let held_parent = crate::held::Identity::of(&parent.metadata()?);
817 if public == Some(held_parent) {
818 return Ok(());
819 }
820 Err(cleanup(
821 parent,
822 &prepared.name,
823 temp.identity,
824 std::io::Error::other(format!(
825 "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",
826 prepared.parent.display()
827 )),
828 ))
829}
830
831fn cleanup(
835 parent: &File,
836 name: &std::ffi::OsStr,
837 identity: crate::held::Identity,
838 error: std::io::Error,
839) -> std::io::Error {
840 crate::held::pause(PAUSE_BEFORE_CLEANUP_VAR, "stopped", "proceed");
841 let base = crate::held::proc_path(parent);
842 let (quarantined, current) = match crate::held::quarantine(parent, name, QUARANTINE_PREFIX) {
843 Ok(moved) => moved,
844 Err(quarantine) => {
845 return std::io::Error::new(
846 error.kind(),
847 format!(
848 "{error}; the entry under {} could not be quarantined and was left in place: {quarantine}",
849 name.display()
850 ),
851 );
852 }
853 };
854 if current.file_type().is_symlink() || crate::held::Identity::of(¤t) != identity {
855 return std::io::Error::new(
856 error.kind(),
857 format!(
858 "{error}; the entry under {} was not the directory this run created, so it was moved to {} and left in place",
859 name.display(),
860 quarantined.display()
861 ),
862 );
863 }
864 match fs::remove_dir_all(base.join(&quarantined)) {
865 Ok(()) => error,
866 Err(removal) => std::io::Error::new(
867 error.kind(),
868 format!(
869 "{error}; the directory was moved to {} and could not be removed: {removal}",
870 quarantined.display()
871 ),
872 ),
873 }
874}
875
876fn write_into(temp: &Temp, composed: &Composed, stop: Option<&Path>) -> std::io::Result<()> {
879 let base = crate::held::proc_path(&temp.dir);
880 for (path, bytes) in &composed.files {
881 let destination = base.join(path);
882 if let Some(parent) = destination.parent() {
883 fs::create_dir_all(parent)?;
884 }
885 fs::write(&destination, bytes)?;
886 if stop.is_some_and(|stop| Path::new(path) == stop) {
887 return Err(std::io::Error::other(format!(
888 "the stage was stopped after {path} for the proof"
889 )));
890 }
891 }
892 let text = serde_json::to_string_pretty(&composed.receipt).map_err(std::io::Error::other)?;
893 let mut receipt = fs::OpenOptions::new()
894 .write(true)
895 .create_new(true)
896 .mode(0o600)
897 .open(base.join(RECEIPT_NAME))?;
898 receipt.write_all(text.as_bytes())?;
899 receipt.write_all(b"\n")?;
900 receipt.sync_all()?;
901 Ok(())
902}
903
904#[must_use]
908pub fn recorded_schema_version(target: &Utf8Path) -> Option<u64> {
909 let bytes = fs::read(target.join(crate::landing::manifest::MANIFEST_PATH)).ok()?;
910 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
911 value.get("schema_version")?.as_u64()
912}
913
914#[cfg(test)]
915mod tests {
916 use super::{
917 CandidateEntry, Note, Parameters, REFERENCE_ROOTS, Receipt, STAGE_SCHEMA, reference_files,
918 target_key,
919 };
920 use crate::digest::Digest;
921 use crate::landing::Kind;
922 use crate::landing::manifest::{CheckoutMode, Style};
923 use crate::profile::{
924 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
925 };
926
927 fn test_parameters() -> Parameters {
930 Parameters {
931 profile: ProfileSnapshot {
932 technologies: vec!["rust".into()],
933 forge: Some("github".into()),
934 release: ReleaseIntent {
935 mode: ReleaseMode::Automatic,
936 driver: Some("rust".into()),
937 style: Some(Style::Trunk),
938 line_prefix: Some("release/".into()),
939 },
940 },
941 git: GitWorkflow {
942 trunk: "master".into(),
943 checkout_mode: CheckoutMode::LinkedWorktree,
944 },
945 capabilities: CapabilityRequests {
946 nix_packaging: false,
947 reporting_policy: true,
948 scorecard: false,
949 code_scanning: None,
950 },
951 repo: "acme/widget".into(),
952 security_contact: String::new(),
953 security_response: "best-effort".into(),
954 }
955 }
956
957 #[test]
960 fn the_stage_receipt_schema_snapshot_holds() {
961 let receipt = Receipt {
962 schema: STAGE_SCHEMA.to_owned(),
963 rk_version: "0.0.0".into(),
964 target: "/tmp/t".into(),
965 stage_root: "/tmp/s".into(),
966 parameters: test_parameters(),
967 capabilities: vec![],
968 receipt_schema_version: Some(6),
969 candidates: vec![
970 CandidateEntry {
971 destination: "AGENTS.md".into(),
972 kind: Kind::Rendered,
973 placement: "region".into(),
974 sha256: Digest::of(b"a"),
975 region_sha256: Some(Digest::of(b"r")),
976 sources: vec!["blocks/routing.md.in".into()],
977 },
978 CandidateEntry {
979 destination: "release-plz.toml".into(),
980 kind: Kind::Seeded,
981 placement: "whole".into(),
982 sha256: Digest::of(b"b"),
983 region_sha256: None,
984 sources: vec!["snippets/rust/github/release-plz.toml".into()],
985 },
986 ],
987 omissions: vec![Note {
988 destination: "flake.nix".into(),
989 reason: "the target already carries flake.nix".into(),
990 action: None,
991 }],
992 collisions: vec![],
993 retired: vec!["old.yml".into()],
994 seeded_present: vec!["release-plz.toml".into()],
995 state_present: vec![],
996 reference: REFERENCE_ROOTS
997 .iter()
998 .map(|root| (*root).to_owned())
999 .collect(),
1000 };
1001 assert_eq!(
1002 serde_json::to_string(&receipt).expect("a receipt serializes"),
1003 format!(
1004 r#"{{"schema":"rk.stage/4","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"}},"capabilities":{{"nix_packaging":false,"reporting_policy":true,"scorecard":false}},"repo":"acme/widget","security_contact":"","security_response":"best-effort"}},"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":[],"reference":["CHANGELOG.md","guidance","method","bindings","runbooks","forges","skills/rk-setup","skill-shared"]}}"#,
1005 Digest::of(b"a"),
1006 Digest::of(b"r"),
1007 Digest::of(b"b")
1008 )
1009 );
1010 let back: Receipt =
1011 serde_json::from_str(&serde_json::to_string(&receipt).expect("serializes"))
1012 .expect("a receipt reads back");
1013 assert_eq!(back.stage_root, "/tmp/s");
1014 }
1015
1016 #[test]
1019 fn every_reference_root_serves_a_file_and_nothing_else_is_served() {
1020 let files = reference_files();
1021 for root in REFERENCE_ROOTS {
1022 assert!(
1023 files
1024 .iter()
1025 .any(|(path, _)| path == root || path.starts_with(&format!("{root}/"))),
1026 "{root}: the reference tree carries no file for it"
1027 );
1028 }
1029 for (path, _) in &files {
1030 assert!(
1031 REFERENCE_ROOTS
1032 .iter()
1033 .any(|root| path == root || path.starts_with(&format!("{root}/"))),
1034 "{path}: outside every declared reference root"
1035 );
1036 assert!(
1037 !path
1038 .split('/')
1039 .any(|part| part == "_docs" || part == "tests" || part == "src"),
1040 "{path}: an instance-owned or source path in the reference tree"
1041 );
1042 }
1043 }
1044
1045 #[test]
1049 fn a_pre_existing_temp_sibling_is_never_touched() {
1050 let scratch = tempfile::tempdir().expect("a scratch dir exists");
1051 let parent = std::fs::canonicalize(scratch.path()).expect("canonical");
1052 let output = parent.join("stage");
1053 let target = parent.join("target");
1054 std::fs::create_dir(&target).expect("creates");
1055 let prepared =
1056 super::prepare(&output, super::OutputSource::Flag, &target).expect("prepares");
1057 let stranger = parent.join(super::temp_name(std::ffi::OsStr::new("stage"), 0));
1058 std::fs::create_dir_all(stranger.join("deep")).expect("creates");
1059 std::fs::write(stranger.join("deep/canary"), b"not yours").expect("writes");
1060 std::fs::write(stranger.join("canary"), b"still not yours").expect("writes");
1061 let composed = super::Composed {
1062 files: vec![(
1063 "artifacts/a.txt".to_owned(),
1064 std::borrow::Cow::Borrowed(b"a"),
1065 )],
1066 receipt: sample_receipt(),
1067 };
1068 super::write(&prepared, &composed).expect("the write lands beside the stranger");
1069 assert_eq!(
1070 std::fs::read(output.join("artifacts/a.txt")).expect("reads"),
1071 b"a"
1072 );
1073 assert_eq!(
1074 std::fs::read(stranger.join("deep/canary")).expect("the stranger reads"),
1075 b"not yours"
1076 );
1077 assert_eq!(
1078 std::fs::read(stranger.join("canary")).expect("the stranger reads"),
1079 b"still not yours"
1080 );
1081 let output_two = parent.join("stage-two");
1083 let prepared =
1084 super::prepare(&output_two, super::OutputSource::Flag, &target).expect("prepares");
1085 let stranger_two = parent.join(super::temp_name(std::ffi::OsStr::new("stage-two"), 0));
1086 std::fs::create_dir(&stranger_two).expect("creates");
1087 std::fs::write(stranger_two.join("canary"), b"kept").expect("writes");
1088 let stopped = super::write_stopping_at(
1089 &prepared,
1090 &composed,
1091 Some(std::path::Path::new("artifacts/a.txt")),
1092 );
1093 assert!(stopped.is_err());
1094 assert!(!output_two.exists());
1095 assert_eq!(
1096 std::fs::read(stranger_two.join("canary")).expect("the stranger reads"),
1097 b"kept"
1098 );
1099 let leftovers: Vec<String> = std::fs::read_dir(&parent)
1100 .expect("reads")
1101 .map(|entry| {
1102 entry
1103 .expect("an entry")
1104 .file_name()
1105 .to_string_lossy()
1106 .into_owned()
1107 })
1108 .filter(|name| name.starts_with(".rk-stage-"))
1109 .collect();
1110 assert_eq!(
1111 leftovers.len(),
1112 2,
1113 "only the two strangers remain: {leftovers:?}"
1114 );
1115 }
1116
1117 #[test]
1120 fn a_stage_root_inside_the_target_refuses_before_anything_is_created() {
1121 let scratch = tempfile::tempdir().expect("a scratch dir exists");
1122 let target = std::fs::canonicalize(scratch.path())
1123 .expect("canonical")
1124 .join("t");
1125 std::fs::create_dir(&target).expect("creates");
1126 for (output, source) in [
1127 (target.join("stage"), super::OutputSource::Flag),
1128 (
1129 target.join("deep/er/stage"),
1130 super::OutputSource::Environment,
1131 ),
1132 (
1133 target.join("state/release-kit/stages/k/v"),
1134 super::OutputSource::StateRoot,
1135 ),
1136 (target.clone(), super::OutputSource::Flag),
1137 ] {
1138 let error = super::prepare(&output, source, &target).expect_err("refuses");
1139 assert_eq!(
1140 error.reason(),
1141 crate::diagnostic::Reason::DestructiveRefusal
1142 );
1143 assert_eq!(error.exit_code(), 73);
1144 }
1145 assert_eq!(
1146 std::fs::read_dir(&target).expect("reads").count(),
1147 0,
1148 "a refusal created a component inside the target"
1149 );
1150 }
1151
1152 fn sample_receipt() -> Receipt {
1153 Receipt {
1154 schema: STAGE_SCHEMA.to_owned(),
1155 rk_version: "0.0.0".into(),
1156 target: "/tmp/t".into(),
1157 stage_root: "/tmp/s".into(),
1158 parameters: test_parameters(),
1159 capabilities: vec![],
1160 receipt_schema_version: None,
1161 candidates: vec![],
1162 omissions: vec![],
1163 collisions: vec![],
1164 retired: vec![],
1165 seeded_present: vec![],
1166 state_present: vec![],
1167 reference: vec![],
1168 }
1169 }
1170
1171 #[test]
1174 fn the_target_key_is_one_flat_digest() {
1175 let key = target_key(std::path::Path::new("/a/b"));
1176 assert_eq!(key.len(), 64);
1177 assert!(key.bytes().all(|b| b.is_ascii_hexdigit()));
1178 assert_ne!(key, target_key(std::path::Path::new("/a/c")));
1179 }
1180}