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, Style, Workflow};
35use crate::landing::{Kind, Params};
36use crate::projection::{Placement, Projection};
37use crate::skills;
38
39pub const STAGE_SCHEMA: &str = "rk.stage/1";
41
42pub const RECEIPT_NAME: &str = "stage.json";
44
45pub const OUTPUT_ROOT_VAR: &str = "RK_STAGE_ROOT";
47
48pub const STAGES_DIR: &str = "stages";
50
51pub const ARTIFACTS_DIR: &str = "artifacts";
53
54pub const REFERENCE_DIR: &str = "reference";
56
57pub const SETUP_SKILL: &str = "rk-setup";
60
61pub const INTERRUPT_VAR: &str = "RK_STAGE_INTERRUPT_AT";
64
65pub const REFERENCE_ROOTS: [&str; 8] = [
68 "CHANGELOG.md",
69 "guidance",
70 "method",
71 "bindings",
72 "runbooks",
73 "forges",
74 "skills/rk-setup",
75 "skill-shared",
76];
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct Receipt {
82 pub schema: String,
84 pub rk_version: String,
86 pub target: String,
88 pub stage_root: String,
90 pub parameters: Parameters,
92 pub receipt_schema_version: Option<u64>,
95 pub candidates: Vec<CandidateEntry>,
97 pub omissions: Vec<Note>,
99 pub collisions: Vec<Note>,
101 pub retired: Vec<String>,
104 pub seeded_present: Vec<String>,
107 pub state_present: Vec<String>,
110 pub reference: Vec<String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Parameters {
117 pub tech: String,
119 pub forge: String,
121 pub repo: String,
123 pub workflow: Workflow,
125 pub style: Option<Style>,
127 pub nix: bool,
129 pub trunk: String,
131 pub line_prefix: String,
133 pub security_contact: String,
135 pub security_response: String,
137}
138
139impl From<&Params> for Parameters {
140 fn from(params: &Params) -> Self {
141 Self {
142 tech: params.tech().to_owned(),
143 forge: params.forge().to_owned(),
144 repo: params.repo().to_owned(),
145 workflow: params.workflow(),
146 style: params.style(),
147 nix: params.nix(),
148 trunk: params.trunk().to_owned(),
149 line_prefix: params.line_prefix().to_owned(),
150 security_contact: params.security_contact().to_owned(),
151 security_response: params.security_response().to_owned(),
152 }
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct CandidateEntry {
159 pub destination: String,
161 pub kind: Kind,
163 pub placement: String,
166 pub sha256: Digest,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub region_sha256: Option<Digest>,
171 pub sources: Vec<String>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Note {
178 pub destination: String,
180 pub reason: String,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum OutputSource {
187 Flag,
189 Environment,
191 StateRoot,
193}
194
195impl OutputSource {
196 #[must_use]
198 pub const fn as_str(self) -> &'static str {
199 match self {
200 Self::Flag => "--output",
201 Self::Environment => "RK_STAGE_ROOT",
202 Self::StateRoot => "state root",
203 }
204 }
205}
206
207#[must_use]
213pub fn target_key(canonical_target: &Path) -> String {
214 Digest::of(canonical_target.display().to_string().as_bytes()).to_string()
215}
216
217pub fn resolve_output(
226 flag: Option<&Utf8Path>,
227 canonical_target: &Path,
228) -> Result<(PathBuf, OutputSource), RkError> {
229 if let Some(flag) = flag {
230 let path = if flag.is_absolute() {
231 flag.as_std_path().to_path_buf()
232 } else {
233 std::env::current_dir()?.join(flag.as_std_path())
234 };
235 return Ok((path, OutputSource::Flag));
236 }
237 let leaf = Path::new(&target_key(canonical_target)).join(env!("CARGO_PKG_VERSION"));
238 if let Some(base) = std::env::var_os(OUTPUT_ROOT_VAR).filter(|value| !value.is_empty()) {
239 let base = PathBuf::from(base);
240 let base = if base.is_absolute() {
241 base
242 } else {
243 std::env::current_dir()?.join(base)
244 };
245 return Ok((base.join(leaf), OutputSource::Environment));
246 }
247 let Some(root) = applog::state_root() else {
248 return Err(RkError::refusal(
249 Diagnostic::new(
250 Reason::PrerequisiteUnmet,
251 "no state root resolves, so the stage has nowhere to go, and nothing was written",
252 )
253 .expected("--output <dir>, RK_STAGE_ROOT, or a state root under XDG_STATE_HOME or HOME")
254 .action("pass --output <dir>, or set XDG_STATE_HOME or HOME, and run it again")
255 .target_state("unchanged"),
256 ));
257 };
258 Ok((root.join(STAGES_DIR).join(leaf), OutputSource::StateRoot))
259}
260
261#[derive(Debug)]
265pub struct Prepared {
266 parent: PathBuf,
267 name: std::ffi::OsString,
268 resolved: PathBuf,
269 owner_only: bool,
270}
271
272impl Prepared {
273 #[must_use]
275 pub fn resolved(&self) -> &Path {
276 &self.resolved
277 }
278}
279
280fn eventual(output: &Path) -> Result<PathBuf, RkError> {
290 let mut existing = output;
291 let mut rest: Vec<&std::ffi::OsStr> = Vec::new();
292 loop {
293 match fs::symlink_metadata(existing) {
294 Ok(_) => break,
295 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
296 Err(error) => return Err(error.into()),
297 }
298 let Some(name) = existing.file_name() else {
299 break;
300 };
301 rest.push(name);
302 existing = existing.parent().unwrap_or_else(|| Path::new("/"));
303 }
304 let mut path = fs::canonicalize(if existing.as_os_str().is_empty() {
305 Path::new(".")
306 } else {
307 existing
308 })?;
309 for name in rest.into_iter().rev() {
310 if name == ".." {
311 return Err(RkError::refusal(
312 Diagnostic::new(
313 Reason::Usage,
314 format!(
315 "{} climbs through a directory that does not exist yet, and nothing was written",
316 output.display()
317 ),
318 )
319 .expected("an output path whose absent components are plain names")
320 .target_state("unchanged"),
321 ));
322 }
323 if name != "." {
324 path.push(name);
325 }
326 }
327 Ok(path)
328}
329
330pub fn prepare(
349 output: &Path,
350 source: OutputSource,
351 canonical_target: &Path,
352) -> Result<Prepared, RkError> {
353 let name = output
354 .file_name()
355 .filter(|name| *name != "." && *name != "..")
356 .ok_or_else(|| {
357 RkError::refusal(
358 Diagnostic::new(
359 Reason::Usage,
360 format!("{} names no directory to stage into", output.display()),
361 )
362 .expected("an output path ending in a directory name")
363 .target_state("unchanged"),
364 )
365 })?
366 .to_owned();
367 let eventual = eventual(output)?;
368 if eventual.starts_with(canonical_target) {
369 return Err(RkError::refusal(
370 Diagnostic::new(
371 Reason::DestructiveRefusal,
372 format!(
373 "the stage would stand at {}, inside the target {}, and nothing was written",
374 eventual.display(),
375 canonical_target.display()
376 ),
377 )
378 .expected("a stage root outside the target repository")
379 .action(match source {
380 OutputSource::Flag => "pass an --output outside the target".to_owned(),
381 OutputSource::Environment => {
382 format!("point {OUTPUT_ROOT_VAR} outside the target, or pass --output")
383 }
384 OutputSource::StateRoot => {
385 "move the state root outside the target, or pass --output".to_owned()
386 }
387 })
388 .target_state("unchanged"),
389 ));
390 }
391 let parent = output
392 .parent()
393 .filter(|parent| !parent.as_os_str().is_empty())
394 .map_or_else(|| PathBuf::from("/"), Path::to_path_buf);
395 let owner_only = source == OutputSource::StateRoot;
396 if owner_only {
397 create_owner_only(&parent)?;
398 } else {
399 fs::create_dir_all(&parent)?;
400 }
401 let parent = fs::canonicalize(&parent)?;
402 let resolved = parent.join(&name);
403 match fs::symlink_metadata(&resolved) {
404 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
405 Err(error) => return Err(error.into()),
406 Ok(metadata) if metadata.is_dir() && fs::read_dir(&resolved)?.next().is_none() => {}
407 Ok(_) => {
408 return Err(RkError::refusal(
409 Diagnostic::new(
410 Reason::StateDrift,
411 format!(
412 "{} already exists and is not empty, and nothing was written",
413 resolved.display()
414 ),
415 )
416 .expected("an absent or empty output directory")
417 .action(format!(
418 "rk stage clean {} removes a stage that stands there; otherwise pass another --output",
419 resolved.display()
420 ))
421 .target_state("unchanged"),
422 ));
423 }
424 }
425 Ok(Prepared {
426 parent,
427 name,
428 resolved,
429 owner_only,
430 })
431}
432
433fn create_owner_only(dir: &Path) -> std::io::Result<()> {
436 fs::DirBuilder::new()
437 .recursive(true)
438 .mode(0o700)
439 .create(dir)?;
440 let Some(state_root) = applog::state_root() else {
443 return Ok(());
444 };
445 let base = state_root.join(STAGES_DIR);
446 let Ok(rest) = dir.strip_prefix(&base) else {
447 return Ok(());
448 };
449 let mut current = base;
450 restrict(¤t)?;
451 for component in rest {
452 current.push(component);
453 restrict(¤t)?;
454 }
455 Ok(())
456}
457
458fn restrict(dir: &Path) -> std::io::Result<()> {
461 let metadata = fs::symlink_metadata(dir)?;
462 if metadata.file_type().is_symlink() || !metadata.is_dir() {
463 return Err(std::io::Error::new(
464 std::io::ErrorKind::InvalidData,
465 format!("stage base is not a directory: {}", dir.display()),
466 ));
467 }
468 fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
469}
470
471#[derive(Debug)]
474pub struct Composed {
475 pub files: Vec<(String, Cow<'static, [u8]>)>,
477 pub receipt: Receipt,
479}
480
481#[must_use]
485pub fn compose(
486 projection: &Projection,
487 params: &Params,
488 canonical_target: &Path,
489 stage_root: &Path,
490 record: Option<&Manifest>,
491 receipt_schema_version: Option<u64>,
492) -> Composed {
493 let mut files: Vec<(String, Cow<'static, [u8]>)> = Vec::new();
494 let mut candidates = Vec::new();
495 for candidate in &projection.candidates {
496 files.push((
497 format!("{ARTIFACTS_DIR}/{}", candidate.destination),
498 Cow::Owned(candidate.bytes.clone()),
499 ));
500 candidates.push(CandidateEntry {
501 destination: candidate.destination.clone(),
502 kind: candidate.kind,
503 placement: match candidate.placement {
504 Placement::Whole => "whole",
505 Placement::Region { .. } => "region",
506 }
507 .to_owned(),
508 sha256: Digest::of(&candidate.bytes),
509 region_sha256: candidate.region.as_deref().map(Digest::of),
510 sources: candidate.sources.clone(),
511 });
512 }
513 for (path, bytes) in reference_files() {
514 files.push((format!("{REFERENCE_DIR}/{path}"), Cow::Borrowed(bytes)));
515 }
516 files.sort_by(|a, b| a.0.cmp(&b.0));
517 let produced = |destination: &str| {
518 projection
519 .candidates
520 .iter()
521 .any(|candidate| candidate.destination == destination)
522 || projection
523 .omissions
524 .iter()
525 .any(|omission| omission.destination == destination)
526 };
527 let mut retired = Vec::new();
528 let mut seeded_present = Vec::new();
529 let mut state_present = Vec::new();
530 if let Some(record) = record {
531 for file in &record.files {
532 if !produced(&file.destination) {
533 retired.push(file.destination.clone());
534 }
535 let present = fs::symlink_metadata(canonical_target.join(&file.destination)).is_ok();
536 match file.kind {
537 Kind::Seeded if present => seeded_present.push(file.destination.clone()),
538 Kind::State if present => state_present.push(file.destination.clone()),
539 Kind::Rendered | Kind::Seeded | Kind::State => {}
540 }
541 }
542 }
543 let receipt = Receipt {
544 schema: STAGE_SCHEMA.to_owned(),
545 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
546 target: canonical_target.display().to_string(),
547 stage_root: stage_root.display().to_string(),
548 parameters: Parameters::from(params),
549 receipt_schema_version,
550 candidates,
551 omissions: projection
552 .omissions
553 .iter()
554 .map(|omission| Note {
555 destination: omission.destination.clone(),
556 reason: omission.reason.clone(),
557 })
558 .collect(),
559 collisions: projection
560 .collisions
561 .iter()
562 .map(|collision| Note {
563 destination: collision.destination.clone(),
564 reason: collision.reason.clone(),
565 })
566 .collect(),
567 retired,
568 seeded_present,
569 state_present,
570 reference: REFERENCE_ROOTS
571 .iter()
572 .map(|root| (*root).to_owned())
573 .collect(),
574 };
575 Composed { files, receipt }
576}
577
578#[must_use]
586pub fn reference_files() -> Vec<(String, &'static [u8])> {
587 let mut out: Vec<(String, &'static [u8])> =
588 vec![("CHANGELOG.md".to_owned(), embedded::CHANGELOG.as_bytes())];
589 for (root, dir) in [
590 ("guidance", &embedded::GUIDANCE),
591 ("method", &embedded::METHOD),
592 ("bindings", &embedded::BINDINGS),
593 ("runbooks", &embedded::RUNBOOKS),
594 ("forges", &embedded::FORGES),
595 ] {
596 for (path, bytes) in embedded::walk(dir) {
597 out.push((format!("{root}/{path}"), bytes));
598 }
599 }
600 let prefix = format!("{SETUP_SKILL}/");
601 let mut skill_text = String::new();
602 for (path, bytes) in embedded::walk(&embedded::SKILLS) {
603 if path.starts_with(&prefix) {
604 if path == format!("{prefix}SKILL.md") {
605 skill_text = String::from_utf8_lossy(bytes).into_owned();
606 }
607 out.push((format!("skills/{path}"), bytes));
608 }
609 }
610 for artifact in skills::shared() {
611 if skill_text.contains(&artifact.path) {
612 out.push((format!("skill-shared/{}", artifact.path), artifact.bytes));
613 }
614 }
615 out
616}
617
618const TEMP_ATTEMPTS: u32 = 8;
620
621pub const PAUSE_BEFORE_CLEANUP_VAR: &str = "RK_STAGE_PAUSE_BEFORE_CLEANUP";
625
626const QUARANTINE_PREFIX: &str = ".rk-stage-quarantine-";
628
629const CLAIM_PREFIX: &str = ".rk-stage-claim-";
632
633pub const PAUSE_BEFORE_LAND_VAR: &str = "RK_STAGE_PAUSE_BEFORE_LAND";
637
638pub const PAUSE_AFTER_LAND_VAR: &str = "RK_STAGE_PAUSE_AFTER_LAND";
644
645fn temp_name(name: &std::ffi::OsStr, attempt: u32) -> std::ffi::OsString {
649 let mut out = std::ffi::OsString::from(format!(".rk-stage-{}", std::process::id()));
650 if attempt > 0 {
651 out.push(format!("-{:08x}", crate::held::nonce() & 0xffff_ffff));
652 }
653 out.push(".");
654 out.push(name);
655 out
656}
657
658struct Temp {
662 name: std::ffi::OsString,
663 dir: File,
664 identity: crate::held::Identity,
665}
666
667fn create_temp(prepared: &Prepared, parent: &File) -> std::io::Result<Temp> {
671 let mut builder = fs::DirBuilder::new();
672 if prepared.owner_only {
673 builder.mode(0o700);
674 }
675 let base = crate::held::proc_path(parent);
676 for attempt in 0..TEMP_ATTEMPTS {
677 let name = temp_name(&prepared.name, attempt);
678 match builder.create(base.join(&name)) {
679 Ok(()) => {
680 let dir = crate::held::open_dir(&base.join(&name))?;
681 let identity = crate::held::Identity::of(&dir.metadata()?);
682 return Ok(Temp {
683 name,
684 dir,
685 identity,
686 });
687 }
688 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
689 Err(error) => return Err(error),
690 }
691 }
692 Err(std::io::Error::new(
693 std::io::ErrorKind::AlreadyExists,
694 format!(
695 "every sibling name for the stage below {} is taken, and nothing was written or removed",
696 prepared.parent.display()
697 ),
698 ))
699}
700
701pub fn write(prepared: &Prepared, composed: &Composed) -> Result<(), RkError> {
717 let stop = std::env::var_os(INTERRUPT_VAR).map(PathBuf::from);
718 write_stopping_at(prepared, composed, stop.as_deref())
719}
720
721pub fn write_stopping_at(
728 prepared: &Prepared,
729 composed: &Composed,
730 stop: Option<&Path>,
731) -> Result<(), RkError> {
732 let parent = crate::held::open_dir(&prepared.parent)?;
733 let temp = create_temp(prepared, &parent)?;
734 if let Err(error) = write_into(&temp, composed, stop) {
735 return Err(RkError::Io(cleanup(
736 &parent,
737 &temp.name,
738 temp.identity,
739 error,
740 )));
741 }
742 land(&parent, &temp, prepared).map_err(RkError::Io)
743}
744
745fn land(parent: &File, temp: &Temp, prepared: &Prepared) -> std::io::Result<()> {
759 crate::held::pause(PAUSE_BEFORE_LAND_VAR, "finished", "proceed");
760 let base = crate::held::proc_path(parent);
761 let (claim, current) = crate::held::quarantine(parent, &temp.name, CLAIM_PREFIX)?;
762 if current.file_type().is_symlink() || crate::held::Identity::of(¤t) != temp.identity {
763 return Err(std::io::Error::other(format!(
764 "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",
765 prepared.parent.join(&temp.name).display(),
766 claim.display()
767 )));
768 }
769 if let Err(error) = fs::rename(base.join(&claim), base.join(&prepared.name)) {
770 return Err(cleanup(parent, &claim, temp.identity, error));
771 }
772 crate::held::pause(PAUSE_AFTER_LAND_VAR, "landed", "proceed");
773 let public = fs::metadata(&prepared.parent)
774 .ok()
775 .map(|metadata| crate::held::Identity::of(&metadata));
776 let held_parent = crate::held::Identity::of(&parent.metadata()?);
777 if public == Some(held_parent) {
778 return Ok(());
779 }
780 Err(cleanup(
781 parent,
782 &prepared.name,
783 temp.identity,
784 std::io::Error::other(format!(
785 "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",
786 prepared.parent.display()
787 )),
788 ))
789}
790
791fn cleanup(
795 parent: &File,
796 name: &std::ffi::OsStr,
797 identity: crate::held::Identity,
798 error: std::io::Error,
799) -> std::io::Error {
800 crate::held::pause(PAUSE_BEFORE_CLEANUP_VAR, "stopped", "proceed");
801 let base = crate::held::proc_path(parent);
802 let (quarantined, current) = match crate::held::quarantine(parent, name, QUARANTINE_PREFIX) {
803 Ok(moved) => moved,
804 Err(quarantine) => {
805 return std::io::Error::new(
806 error.kind(),
807 format!(
808 "{error}; the entry under {} could not be quarantined and was left in place: {quarantine}",
809 name.display()
810 ),
811 );
812 }
813 };
814 if current.file_type().is_symlink() || crate::held::Identity::of(¤t) != identity {
815 return std::io::Error::new(
816 error.kind(),
817 format!(
818 "{error}; the entry under {} was not the directory this run created, so it was moved to {} and left in place",
819 name.display(),
820 quarantined.display()
821 ),
822 );
823 }
824 match fs::remove_dir_all(base.join(&quarantined)) {
825 Ok(()) => error,
826 Err(removal) => std::io::Error::new(
827 error.kind(),
828 format!(
829 "{error}; the directory was moved to {} and could not be removed: {removal}",
830 quarantined.display()
831 ),
832 ),
833 }
834}
835
836fn write_into(temp: &Temp, composed: &Composed, stop: Option<&Path>) -> std::io::Result<()> {
839 let base = crate::held::proc_path(&temp.dir);
840 for (path, bytes) in &composed.files {
841 let destination = base.join(path);
842 if let Some(parent) = destination.parent() {
843 fs::create_dir_all(parent)?;
844 }
845 fs::write(&destination, bytes)?;
846 if stop.is_some_and(|stop| Path::new(path) == stop) {
847 return Err(std::io::Error::other(format!(
848 "the stage was stopped after {path} for the proof"
849 )));
850 }
851 }
852 let text = serde_json::to_string_pretty(&composed.receipt).map_err(std::io::Error::other)?;
853 let mut receipt = fs::OpenOptions::new()
854 .write(true)
855 .create_new(true)
856 .mode(0o600)
857 .open(base.join(RECEIPT_NAME))?;
858 receipt.write_all(text.as_bytes())?;
859 receipt.write_all(b"\n")?;
860 receipt.sync_all()?;
861 Ok(())
862}
863
864#[must_use]
868pub fn recorded_schema_version(target: &Utf8Path) -> Option<u64> {
869 let bytes = fs::read(target.join(crate::landing::manifest::MANIFEST_PATH)).ok()?;
870 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
871 value.get("schema_version")?.as_u64()
872}
873
874#[cfg(test)]
875mod tests {
876 use super::{
877 CandidateEntry, Note, Parameters, REFERENCE_ROOTS, Receipt, STAGE_SCHEMA, reference_files,
878 target_key,
879 };
880 use crate::digest::Digest;
881 use crate::landing::Kind;
882 use crate::landing::manifest::{Style, Workflow};
883
884 #[test]
887 fn the_stage_receipt_schema_snapshot_holds() {
888 let receipt = Receipt {
889 schema: STAGE_SCHEMA.to_owned(),
890 rk_version: "0.0.0".into(),
891 target: "/tmp/t".into(),
892 stage_root: "/tmp/s".into(),
893 parameters: Parameters {
894 tech: "rust".into(),
895 forge: "github".into(),
896 repo: "acme/widget".into(),
897 workflow: Workflow::Worktree,
898 style: Some(Style::Trunk),
899 nix: false,
900 trunk: "master".into(),
901 line_prefix: "release/".into(),
902 security_contact: String::new(),
903 security_response: "best-effort".into(),
904 },
905 receipt_schema_version: Some(6),
906 candidates: vec![
907 CandidateEntry {
908 destination: "AGENTS.md".into(),
909 kind: Kind::Rendered,
910 placement: "region".into(),
911 sha256: Digest::of(b"a"),
912 region_sha256: Some(Digest::of(b"r")),
913 sources: vec!["blocks/routing.md.in".into()],
914 },
915 CandidateEntry {
916 destination: "release-plz.toml".into(),
917 kind: Kind::Seeded,
918 placement: "whole".into(),
919 sha256: Digest::of(b"b"),
920 region_sha256: None,
921 sources: vec!["snippets/rust/github/release-plz.toml".into()],
922 },
923 ],
924 omissions: vec![Note {
925 destination: "flake.nix".into(),
926 reason: "the target already carries flake.nix".into(),
927 }],
928 collisions: vec![],
929 retired: vec!["old.yml".into()],
930 seeded_present: vec!["release-plz.toml".into()],
931 state_present: vec![],
932 reference: REFERENCE_ROOTS
933 .iter()
934 .map(|root| (*root).to_owned())
935 .collect(),
936 };
937 assert_eq!(
938 serde_json::to_string(&receipt).expect("a receipt serializes"),
939 format!(
940 r#"{{"schema":"rk.stage/1","rk_version":"0.0.0","target":"/tmp/t","stage_root":"/tmp/s","parameters":{{"tech":"rust","forge":"github","repo":"acme/widget","workflow":"worktree","style":"trunk","nix":false,"trunk":"master","line_prefix":"release/","security_contact":"","security_response":"best-effort"}},"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"]}}"#,
941 Digest::of(b"a"),
942 Digest::of(b"r"),
943 Digest::of(b"b")
944 )
945 );
946 let back: Receipt =
947 serde_json::from_str(&serde_json::to_string(&receipt).expect("serializes"))
948 .expect("a receipt reads back");
949 assert_eq!(back.stage_root, "/tmp/s");
950 }
951
952 #[test]
955 fn every_reference_root_serves_a_file_and_nothing_else_is_served() {
956 let files = reference_files();
957 for root in REFERENCE_ROOTS {
958 assert!(
959 files
960 .iter()
961 .any(|(path, _)| path == root || path.starts_with(&format!("{root}/"))),
962 "{root}: the reference tree carries no file for it"
963 );
964 }
965 for (path, _) in &files {
966 assert!(
967 REFERENCE_ROOTS
968 .iter()
969 .any(|root| path == root || path.starts_with(&format!("{root}/"))),
970 "{path}: outside every declared reference root"
971 );
972 assert!(
973 !path
974 .split('/')
975 .any(|part| part == "_docs" || part == "tests" || part == "src"),
976 "{path}: an instance-owned or source path in the reference tree"
977 );
978 }
979 }
980
981 #[test]
985 fn a_pre_existing_temp_sibling_is_never_touched() {
986 let scratch = tempfile::tempdir().expect("a scratch dir exists");
987 let parent = std::fs::canonicalize(scratch.path()).expect("canonical");
988 let output = parent.join("stage");
989 let target = parent.join("target");
990 std::fs::create_dir(&target).expect("creates");
991 let prepared =
992 super::prepare(&output, super::OutputSource::Flag, &target).expect("prepares");
993 let stranger = parent.join(super::temp_name(std::ffi::OsStr::new("stage"), 0));
994 std::fs::create_dir_all(stranger.join("deep")).expect("creates");
995 std::fs::write(stranger.join("deep/canary"), b"not yours").expect("writes");
996 std::fs::write(stranger.join("canary"), b"still not yours").expect("writes");
997 let composed = super::Composed {
998 files: vec![(
999 "artifacts/a.txt".to_owned(),
1000 std::borrow::Cow::Borrowed(b"a"),
1001 )],
1002 receipt: sample_receipt(),
1003 };
1004 super::write(&prepared, &composed).expect("the write lands beside the stranger");
1005 assert_eq!(
1006 std::fs::read(output.join("artifacts/a.txt")).expect("reads"),
1007 b"a"
1008 );
1009 assert_eq!(
1010 std::fs::read(stranger.join("deep/canary")).expect("the stranger reads"),
1011 b"not yours"
1012 );
1013 assert_eq!(
1014 std::fs::read(stranger.join("canary")).expect("the stranger reads"),
1015 b"still not yours"
1016 );
1017 let output_two = parent.join("stage-two");
1019 let prepared =
1020 super::prepare(&output_two, super::OutputSource::Flag, &target).expect("prepares");
1021 let stranger_two = parent.join(super::temp_name(std::ffi::OsStr::new("stage-two"), 0));
1022 std::fs::create_dir(&stranger_two).expect("creates");
1023 std::fs::write(stranger_two.join("canary"), b"kept").expect("writes");
1024 let stopped = super::write_stopping_at(
1025 &prepared,
1026 &composed,
1027 Some(std::path::Path::new("artifacts/a.txt")),
1028 );
1029 assert!(stopped.is_err());
1030 assert!(!output_two.exists());
1031 assert_eq!(
1032 std::fs::read(stranger_two.join("canary")).expect("the stranger reads"),
1033 b"kept"
1034 );
1035 let leftovers: Vec<String> = std::fs::read_dir(&parent)
1036 .expect("reads")
1037 .map(|entry| {
1038 entry
1039 .expect("an entry")
1040 .file_name()
1041 .to_string_lossy()
1042 .into_owned()
1043 })
1044 .filter(|name| name.starts_with(".rk-stage-"))
1045 .collect();
1046 assert_eq!(
1047 leftovers.len(),
1048 2,
1049 "only the two strangers remain: {leftovers:?}"
1050 );
1051 }
1052
1053 #[test]
1056 fn a_stage_root_inside_the_target_refuses_before_anything_is_created() {
1057 let scratch = tempfile::tempdir().expect("a scratch dir exists");
1058 let target = std::fs::canonicalize(scratch.path())
1059 .expect("canonical")
1060 .join("t");
1061 std::fs::create_dir(&target).expect("creates");
1062 for (output, source) in [
1063 (target.join("stage"), super::OutputSource::Flag),
1064 (
1065 target.join("deep/er/stage"),
1066 super::OutputSource::Environment,
1067 ),
1068 (
1069 target.join("state/release-kit/stages/k/v"),
1070 super::OutputSource::StateRoot,
1071 ),
1072 (target.clone(), super::OutputSource::Flag),
1073 ] {
1074 let error = super::prepare(&output, source, &target).expect_err("refuses");
1075 assert_eq!(
1076 error.reason(),
1077 crate::diagnostic::Reason::DestructiveRefusal
1078 );
1079 assert_eq!(error.exit_code(), 73);
1080 }
1081 assert_eq!(
1082 std::fs::read_dir(&target).expect("reads").count(),
1083 0,
1084 "a refusal created a component inside the target"
1085 );
1086 }
1087
1088 fn sample_receipt() -> Receipt {
1089 Receipt {
1090 schema: STAGE_SCHEMA.to_owned(),
1091 rk_version: "0.0.0".into(),
1092 target: "/tmp/t".into(),
1093 stage_root: "/tmp/s".into(),
1094 parameters: Parameters {
1095 tech: "rust".into(),
1096 forge: "github".into(),
1097 repo: "acme/widget".into(),
1098 workflow: Workflow::Worktree,
1099 style: Some(Style::Trunk),
1100 nix: false,
1101 trunk: "master".into(),
1102 line_prefix: "release/".into(),
1103 security_contact: String::new(),
1104 security_response: "best-effort".into(),
1105 },
1106 receipt_schema_version: None,
1107 candidates: vec![],
1108 omissions: vec![],
1109 collisions: vec![],
1110 retired: vec![],
1111 seeded_present: vec![],
1112 state_present: vec![],
1113 reference: vec![],
1114 }
1115 }
1116
1117 #[test]
1120 fn the_target_key_is_one_flat_digest() {
1121 let key = target_key(std::path::Path::new("/a/b"));
1122 assert_eq!(key.len(), 64);
1123 assert!(key.bytes().all(|b| b.is_ascii_hexdigit()));
1124 assert_ne!(key, target_key(std::path::Path::new("/a/c")));
1125 }
1126}