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