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/5";
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 #[serde(default)]
134 pub required_check: String,
135 #[serde(default)]
138 pub required_workflow: String,
139}
140
141impl From<&Params> for Parameters {
142 fn from(params: &Params) -> Self {
143 Self {
144 profile: params.profile().clone(),
145 git: params.git().clone(),
146 capabilities: params.capabilities().clone(),
147 repo: params.repo().to_owned(),
148 security_contact: params.security_contact().to_owned(),
149 security_response: params.security_response().to_owned(),
150 required_check: params.required_check().to_owned(),
151 required_workflow: params.required_workflow().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 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub action: Option<String>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct CapabilityNote {
191 pub id: String,
193 pub status: String,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub reason: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub action: Option<String>,
202 pub destinations: Vec<String>,
204}
205
206impl CapabilityNote {
207 #[must_use]
210 pub fn of(selection: &crate::profile::catalog::Selection, projection: &Projection) -> Self {
211 Self {
212 id: selection.id.to_owned(),
213 status: selection.status.as_str().to_owned(),
214 reason: selection.reason.clone(),
215 action: selection.action.clone(),
216 destinations: projection
217 .candidates
218 .iter()
219 .filter(|candidate| candidate.capability == selection.id)
220 .map(|candidate| candidate.destination.clone())
221 .collect(),
222 }
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum OutputSource {
229 Flag,
231 Environment,
233 StateRoot,
235}
236
237impl OutputSource {
238 #[must_use]
240 pub const fn as_str(self) -> &'static str {
241 match self {
242 Self::Flag => "--output",
243 Self::Environment => "RK_STAGE_ROOT",
244 Self::StateRoot => "state root",
245 }
246 }
247}
248
249#[must_use]
255pub fn target_key(canonical_target: &Path) -> String {
256 Digest::of(canonical_target.display().to_string().as_bytes()).to_string()
257}
258
259pub fn resolve_output(
268 flag: Option<&Utf8Path>,
269 canonical_target: &Path,
270) -> Result<(PathBuf, OutputSource), RkError> {
271 if let Some(flag) = flag {
272 let path = if flag.is_absolute() {
273 flag.as_std_path().to_path_buf()
274 } else {
275 std::env::current_dir()?.join(flag.as_std_path())
276 };
277 return Ok((path, OutputSource::Flag));
278 }
279 let leaf = Path::new(&target_key(canonical_target)).join(env!("CARGO_PKG_VERSION"));
280 if let Some(base) = std::env::var_os(OUTPUT_ROOT_VAR).filter(|value| !value.is_empty()) {
281 let base = PathBuf::from(base);
282 let base = if base.is_absolute() {
283 base
284 } else {
285 std::env::current_dir()?.join(base)
286 };
287 return Ok((base.join(leaf), OutputSource::Environment));
288 }
289 let Some(root) = applog::state_root() else {
290 return Err(RkError::refusal(
291 Diagnostic::new(
292 Reason::PrerequisiteUnmet,
293 "no state root resolves, so the stage has nowhere to go, and nothing was written",
294 )
295 .expected("--output <dir>, RK_STAGE_ROOT, or a state root under XDG_STATE_HOME or HOME")
296 .action("pass --output <dir>, or set XDG_STATE_HOME or HOME, and run it again")
297 .target_state("unchanged"),
298 ));
299 };
300 Ok((root.join(STAGES_DIR).join(leaf), OutputSource::StateRoot))
301}
302
303#[derive(Debug)]
307pub struct Prepared {
308 parent: PathBuf,
309 name: std::ffi::OsString,
310 resolved: PathBuf,
311 owner_only: bool,
312}
313
314impl Prepared {
315 #[must_use]
317 pub fn resolved(&self) -> &Path {
318 &self.resolved
319 }
320}
321
322fn eventual(output: &Path) -> Result<PathBuf, RkError> {
332 let mut existing = output;
333 let mut rest: Vec<&std::ffi::OsStr> = Vec::new();
334 loop {
335 match fs::symlink_metadata(existing) {
336 Ok(_) => break,
337 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
338 Err(error) => return Err(error.into()),
339 }
340 let Some(name) = existing.file_name() else {
341 break;
342 };
343 rest.push(name);
344 existing = existing.parent().unwrap_or_else(|| Path::new("/"));
345 }
346 let mut path = fs::canonicalize(if existing.as_os_str().is_empty() {
347 Path::new(".")
348 } else {
349 existing
350 })?;
351 for name in rest.into_iter().rev() {
352 if name == ".." {
353 return Err(RkError::refusal(
354 Diagnostic::new(
355 Reason::Usage,
356 format!(
357 "{} climbs through a directory that does not exist yet, and nothing was written",
358 output.display()
359 ),
360 )
361 .expected("an output path whose absent components are plain names")
362 .target_state("unchanged"),
363 ));
364 }
365 if name != "." {
366 path.push(name);
367 }
368 }
369 Ok(path)
370}
371
372pub fn prepare(
391 output: &Path,
392 source: OutputSource,
393 canonical_target: &Path,
394) -> Result<Prepared, RkError> {
395 let name = output
396 .file_name()
397 .filter(|name| *name != "." && *name != "..")
398 .ok_or_else(|| {
399 RkError::refusal(
400 Diagnostic::new(
401 Reason::Usage,
402 format!("{} names no directory to stage into", output.display()),
403 )
404 .expected("an output path ending in a directory name")
405 .target_state("unchanged"),
406 )
407 })?
408 .to_owned();
409 let eventual = eventual(output)?;
410 if eventual.starts_with(canonical_target) {
411 return Err(RkError::refusal(
412 Diagnostic::new(
413 Reason::DestructiveRefusal,
414 format!(
415 "the stage would stand at {}, inside the target {}, and nothing was written",
416 eventual.display(),
417 canonical_target.display()
418 ),
419 )
420 .expected("a stage root outside the target repository")
421 .action(match source {
422 OutputSource::Flag => "pass an --output outside the target".to_owned(),
423 OutputSource::Environment => {
424 format!("point {OUTPUT_ROOT_VAR} outside the target, or pass --output")
425 }
426 OutputSource::StateRoot => {
427 "move the state root outside the target, or pass --output".to_owned()
428 }
429 })
430 .target_state("unchanged"),
431 ));
432 }
433 let parent = output
434 .parent()
435 .filter(|parent| !parent.as_os_str().is_empty())
436 .map_or_else(|| PathBuf::from("/"), Path::to_path_buf);
437 let owner_only = source == OutputSource::StateRoot;
438 if owner_only {
439 create_owner_only(&parent)?;
440 } else {
441 fs::create_dir_all(&parent)?;
442 }
443 let parent = fs::canonicalize(&parent)?;
444 let resolved = parent.join(&name);
445 match fs::symlink_metadata(&resolved) {
446 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
447 Err(error) => return Err(error.into()),
448 Ok(metadata) if metadata.is_dir() && fs::read_dir(&resolved)?.next().is_none() => {}
449 Ok(_) => {
450 return Err(RkError::refusal(
451 Diagnostic::new(
452 Reason::StateDrift,
453 format!(
454 "{} already exists and is not empty, and nothing was written",
455 resolved.display()
456 ),
457 )
458 .expected("an absent or empty output directory")
459 .action(format!(
460 "rk stage clean {} removes a stage that stands there; otherwise pass another --output",
461 resolved.display()
462 ))
463 .target_state("unchanged"),
464 ));
465 }
466 }
467 Ok(Prepared {
468 parent,
469 name,
470 resolved,
471 owner_only,
472 })
473}
474
475fn create_owner_only(dir: &Path) -> std::io::Result<()> {
478 fs::DirBuilder::new()
479 .recursive(true)
480 .mode(0o700)
481 .create(dir)?;
482 let Some(state_root) = applog::state_root() else {
485 return Ok(());
486 };
487 let base = state_root.join(STAGES_DIR);
488 let Ok(rest) = dir.strip_prefix(&base) else {
489 return Ok(());
490 };
491 let mut current = base;
492 restrict(¤t)?;
493 for component in rest {
494 current.push(component);
495 restrict(¤t)?;
496 }
497 Ok(())
498}
499
500fn restrict(dir: &Path) -> std::io::Result<()> {
503 let metadata = fs::symlink_metadata(dir)?;
504 if metadata.file_type().is_symlink() || !metadata.is_dir() {
505 return Err(std::io::Error::new(
506 std::io::ErrorKind::InvalidData,
507 format!("stage base is not a directory: {}", dir.display()),
508 ));
509 }
510 fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
511}
512
513#[derive(Debug)]
516pub struct Composed {
517 pub files: Vec<(String, Cow<'static, [u8]>)>,
519 pub receipt: Receipt,
521}
522
523#[must_use]
527pub fn compose(
528 projection: &Projection,
529 params: &Params,
530 canonical_target: &Path,
531 stage_root: &Path,
532 record: Option<&Manifest>,
533 receipt_schema_version: Option<u64>,
534) -> Composed {
535 let mut files: Vec<(String, Cow<'static, [u8]>)> = Vec::new();
536 let mut candidates = Vec::new();
537 for candidate in &projection.candidates {
538 files.push((
539 format!("{ARTIFACTS_DIR}/{}", candidate.destination),
540 Cow::Owned(candidate.bytes.clone()),
541 ));
542 candidates.push(CandidateEntry {
543 destination: candidate.destination.clone(),
544 kind: candidate.kind,
545 placement: match candidate.placement {
546 Placement::Whole => "whole",
547 Placement::Region { .. } => "region",
548 }
549 .to_owned(),
550 sha256: Digest::of(&candidate.bytes),
551 region_sha256: candidate.region.as_deref().map(Digest::of),
552 sources: candidate.sources.clone(),
553 });
554 }
555 for (path, bytes) in reference_files() {
556 files.push((format!("{REFERENCE_DIR}/{path}"), Cow::Borrowed(bytes)));
557 }
558 files.sort_by(|a, b| a.0.cmp(&b.0));
559 let produced = |destination: &str| {
560 projection
561 .candidates
562 .iter()
563 .any(|candidate| candidate.destination == destination)
564 || projection
565 .omissions
566 .iter()
567 .any(|omission| omission.destination == destination)
568 };
569 let mut retired = Vec::new();
570 let mut seeded_present = Vec::new();
571 let mut state_present = Vec::new();
572 if let Some(record) = record {
573 for file in &record.files {
574 if !produced(&file.destination) {
575 retired.push(file.destination.clone());
576 }
577 let present = fs::symlink_metadata(canonical_target.join(&file.destination)).is_ok();
578 match file.kind {
579 Kind::Seeded if present => seeded_present.push(file.destination.clone()),
580 Kind::State if present => state_present.push(file.destination.clone()),
581 Kind::Rendered | Kind::Seeded | Kind::State => {}
582 }
583 }
584 }
585 let receipt = Receipt {
586 schema: STAGE_SCHEMA.to_owned(),
587 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
588 target: canonical_target.display().to_string(),
589 stage_root: stage_root.display().to_string(),
590 parameters: Parameters::from(params),
591 capabilities: projection
592 .capabilities
593 .iter()
594 .map(|selection| CapabilityNote::of(selection, projection))
595 .collect(),
596 receipt_schema_version,
597 candidates,
598 omissions: projection
599 .omissions
600 .iter()
601 .map(|omission| Note {
602 destination: omission.destination.clone(),
603 reason: omission.reason.clone(),
604 action: omission.action.clone(),
605 })
606 .collect(),
607 collisions: projection
608 .collisions
609 .iter()
610 .map(|collision| Note {
611 action: None,
612 destination: collision.destination.clone(),
613 reason: collision.reason.clone(),
614 })
615 .collect(),
616 retired,
617 seeded_present,
618 state_present,
619 reference: REFERENCE_ROOTS
620 .iter()
621 .map(|root| (*root).to_owned())
622 .collect(),
623 };
624 Composed { files, receipt }
625}
626
627#[must_use]
635pub fn reference_files() -> Vec<(String, &'static [u8])> {
636 let mut out: Vec<(String, &'static [u8])> =
637 vec![("CHANGELOG.md".to_owned(), embedded::CHANGELOG.as_bytes())];
638 for (root, dir) in [
639 ("guidance", &embedded::GUIDANCE),
640 ("method", &embedded::METHOD),
641 ("bindings", &embedded::BINDINGS),
642 ("runbooks", &embedded::RUNBOOKS),
643 ("forges", &embedded::FORGES),
644 ] {
645 for (path, bytes) in embedded::walk(dir) {
646 out.push((format!("{root}/{path}"), bytes));
647 }
648 }
649 let prefix = format!("{SETUP_SKILL}/");
650 let mut skill_text = String::new();
651 for (path, bytes) in embedded::walk(&embedded::SKILLS) {
652 if path.starts_with(&prefix) {
653 if path == format!("{prefix}SKILL.md") {
654 skill_text = String::from_utf8_lossy(bytes).into_owned();
655 }
656 out.push((format!("skills/{path}"), bytes));
657 }
658 }
659 for artifact in skills::shared() {
660 if skill_text.contains(&artifact.path) {
661 out.push((format!("skill-shared/{}", artifact.path), artifact.bytes));
662 }
663 }
664 out
665}
666
667const TEMP_ATTEMPTS: u32 = 8;
669
670pub const PAUSE_BEFORE_CLEANUP_VAR: &str = "RK_STAGE_PAUSE_BEFORE_CLEANUP";
674
675const QUARANTINE_PREFIX: &str = ".rk-stage-quarantine-";
677
678const CLAIM_PREFIX: &str = ".rk-stage-claim-";
681
682pub const PAUSE_BEFORE_LAND_VAR: &str = "RK_STAGE_PAUSE_BEFORE_LAND";
686
687pub const PAUSE_AFTER_LAND_VAR: &str = "RK_STAGE_PAUSE_AFTER_LAND";
693
694fn temp_name(name: &std::ffi::OsStr, attempt: u32) -> std::ffi::OsString {
698 let mut out = std::ffi::OsString::from(format!(".rk-stage-{}", std::process::id()));
699 if attempt > 0 {
700 out.push(format!("-{:08x}", crate::held::nonce() & 0xffff_ffff));
701 }
702 out.push(".");
703 out.push(name);
704 out
705}
706
707struct Temp {
711 name: std::ffi::OsString,
712 dir: File,
713 identity: crate::held::Identity,
714}
715
716fn create_temp(prepared: &Prepared, parent: &File) -> std::io::Result<Temp> {
720 let mut builder = fs::DirBuilder::new();
721 if prepared.owner_only {
722 builder.mode(0o700);
723 }
724 let base = crate::held::proc_path(parent);
725 for attempt in 0..TEMP_ATTEMPTS {
726 let name = temp_name(&prepared.name, attempt);
727 match builder.create(base.join(&name)) {
728 Ok(()) => {
729 let dir = crate::held::open_dir(&base.join(&name))?;
730 let identity = crate::held::Identity::of(&dir.metadata()?);
731 return Ok(Temp {
732 name,
733 dir,
734 identity,
735 });
736 }
737 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
738 Err(error) => return Err(error),
739 }
740 }
741 Err(std::io::Error::new(
742 std::io::ErrorKind::AlreadyExists,
743 format!(
744 "every sibling name for the stage below {} is taken, and nothing was written or removed",
745 prepared.parent.display()
746 ),
747 ))
748}
749
750pub fn write(prepared: &Prepared, composed: &Composed) -> Result<(), RkError> {
766 let stop = std::env::var_os(INTERRUPT_VAR).map(PathBuf::from);
767 write_stopping_at(prepared, composed, stop.as_deref())
768}
769
770pub fn write_stopping_at(
777 prepared: &Prepared,
778 composed: &Composed,
779 stop: Option<&Path>,
780) -> Result<(), RkError> {
781 let parent = crate::held::open_dir(&prepared.parent)?;
782 let temp = create_temp(prepared, &parent)?;
783 if let Err(error) = write_into(&temp, composed, stop) {
784 return Err(RkError::Io(cleanup(
785 &parent,
786 &temp.name,
787 temp.identity,
788 error,
789 )));
790 }
791 land(&parent, &temp, prepared).map_err(RkError::Io)
792}
793
794fn land(parent: &File, temp: &Temp, prepared: &Prepared) -> std::io::Result<()> {
808 crate::held::pause(PAUSE_BEFORE_LAND_VAR, "finished", "proceed");
809 let base = crate::held::proc_path(parent);
810 let (claim, current) = crate::held::quarantine(parent, &temp.name, CLAIM_PREFIX)?;
811 if current.file_type().is_symlink() || crate::held::Identity::of(¤t) != temp.identity {
812 return Err(std::io::Error::other(format!(
813 "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",
814 prepared.parent.join(&temp.name).display(),
815 claim.display()
816 )));
817 }
818 if let Err(error) = fs::rename(base.join(&claim), base.join(&prepared.name)) {
819 return Err(cleanup(parent, &claim, temp.identity, error));
820 }
821 crate::held::pause(PAUSE_AFTER_LAND_VAR, "landed", "proceed");
822 let public = fs::metadata(&prepared.parent)
823 .ok()
824 .map(|metadata| crate::held::Identity::of(&metadata));
825 let held_parent = crate::held::Identity::of(&parent.metadata()?);
826 if public == Some(held_parent) {
827 return Ok(());
828 }
829 Err(cleanup(
830 parent,
831 &prepared.name,
832 temp.identity,
833 std::io::Error::other(format!(
834 "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",
835 prepared.parent.display()
836 )),
837 ))
838}
839
840fn cleanup(
844 parent: &File,
845 name: &std::ffi::OsStr,
846 identity: crate::held::Identity,
847 error: std::io::Error,
848) -> std::io::Error {
849 crate::held::pause(PAUSE_BEFORE_CLEANUP_VAR, "stopped", "proceed");
850 let base = crate::held::proc_path(parent);
851 let (quarantined, current) = match crate::held::quarantine(parent, name, QUARANTINE_PREFIX) {
852 Ok(moved) => moved,
853 Err(quarantine) => {
854 return std::io::Error::new(
855 error.kind(),
856 format!(
857 "{error}; the entry under {} could not be quarantined and was left in place: {quarantine}",
858 name.display()
859 ),
860 );
861 }
862 };
863 if current.file_type().is_symlink() || crate::held::Identity::of(¤t) != identity {
864 return std::io::Error::new(
865 error.kind(),
866 format!(
867 "{error}; the entry under {} was not the directory this run created, so it was moved to {} and left in place",
868 name.display(),
869 quarantined.display()
870 ),
871 );
872 }
873 match fs::remove_dir_all(base.join(&quarantined)) {
874 Ok(()) => error,
875 Err(removal) => std::io::Error::new(
876 error.kind(),
877 format!(
878 "{error}; the directory was moved to {} and could not be removed: {removal}",
879 quarantined.display()
880 ),
881 ),
882 }
883}
884
885fn write_into(temp: &Temp, composed: &Composed, stop: Option<&Path>) -> std::io::Result<()> {
888 let base = crate::held::proc_path(&temp.dir);
889 for (path, bytes) in &composed.files {
890 let destination = base.join(path);
891 if let Some(parent) = destination.parent() {
892 fs::create_dir_all(parent)?;
893 }
894 fs::write(&destination, bytes)?;
895 if stop.is_some_and(|stop| Path::new(path) == stop) {
896 return Err(std::io::Error::other(format!(
897 "the stage was stopped after {path} for the proof"
898 )));
899 }
900 }
901 let text = serde_json::to_string_pretty(&composed.receipt).map_err(std::io::Error::other)?;
902 let mut receipt = fs::OpenOptions::new()
903 .write(true)
904 .create_new(true)
905 .mode(0o600)
906 .open(base.join(RECEIPT_NAME))?;
907 receipt.write_all(text.as_bytes())?;
908 receipt.write_all(b"\n")?;
909 receipt.sync_all()?;
910 Ok(())
911}
912
913#[must_use]
917pub fn recorded_schema_version(target: &Utf8Path) -> Option<u64> {
918 let bytes = fs::read(target.join(crate::landing::manifest::MANIFEST_PATH)).ok()?;
919 let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
920 value.get("schema_version")?.as_u64()
921}
922
923#[cfg(test)]
924mod tests {
925 use super::{
926 CandidateEntry, Note, Parameters, REFERENCE_ROOTS, Receipt, STAGE_SCHEMA, reference_files,
927 target_key,
928 };
929 use crate::digest::Digest;
930 use crate::landing::Integration;
931 use crate::landing::Kind;
932 use crate::landing::manifest::{CheckoutMode, Style};
933 use crate::profile::{
934 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
935 };
936
937 fn test_parameters() -> Parameters {
940 Parameters {
941 profile: ProfileSnapshot {
942 technologies: vec!["rust".into()],
943 forge: Some("github".into()),
944 release: ReleaseIntent {
945 mode: ReleaseMode::Automatic,
946 driver: Some("rust".into()),
947 style: Some(Style::Trunk),
948 line_prefix: Some("release/".into()),
949 },
950 },
951 git: GitWorkflow {
952 trunk: "master".into(),
953 checkout_mode: CheckoutMode::LinkedWorktree,
954 integration: Integration::Local,
955 },
956 capabilities: CapabilityRequests {
957 nix_packaging: false,
958 reporting_policy: true,
959 scorecard: false,
960 code_scanning: None,
961 },
962 repo: "acme/widget".into(),
963 security_contact: String::new(),
964 required_check: String::new(),
965 required_workflow: String::new(),
966 security_response: "best-effort".into(),
967 }
968 }
969
970 #[test]
973 fn the_stage_receipt_schema_snapshot_holds() {
974 let receipt = Receipt {
975 schema: STAGE_SCHEMA.to_owned(),
976 rk_version: "0.0.0".into(),
977 target: "/tmp/t".into(),
978 stage_root: "/tmp/s".into(),
979 parameters: test_parameters(),
980 capabilities: vec![],
981 receipt_schema_version: Some(6),
982 candidates: vec![
983 CandidateEntry {
984 destination: "AGENTS.md".into(),
985 kind: Kind::Rendered,
986 placement: "region".into(),
987 sha256: Digest::of(b"a"),
988 region_sha256: Some(Digest::of(b"r")),
989 sources: vec!["blocks/routing.md.in".into()],
990 },
991 CandidateEntry {
992 destination: "release-plz.toml".into(),
993 kind: Kind::Seeded,
994 placement: "whole".into(),
995 sha256: Digest::of(b"b"),
996 region_sha256: None,
997 sources: vec!["snippets/rust/github/release-plz.toml".into()],
998 },
999 ],
1000 omissions: vec![Note {
1001 destination: "flake.nix".into(),
1002 reason: "the target already carries flake.nix".into(),
1003 action: None,
1004 }],
1005 collisions: vec![],
1006 retired: vec!["old.yml".into()],
1007 seeded_present: vec!["release-plz.toml".into()],
1008 state_present: vec![],
1009 reference: REFERENCE_ROOTS
1010 .iter()
1011 .map(|root| (*root).to_owned())
1012 .collect(),
1013 };
1014 assert_eq!(
1015 serde_json::to_string(&receipt).expect("a receipt serializes"),
1016 format!(
1017 r#"{{"schema":"rk.stage/5","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"}},"capabilities":{{"nix_packaging":false,"reporting_policy":true,"scorecard":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":[],"reference":["CHANGELOG.md","guidance","method","bindings","runbooks","forges","skills/rk-setup","skill-shared"]}}"#,
1018 Digest::of(b"a"),
1019 Digest::of(b"r"),
1020 Digest::of(b"b")
1021 )
1022 );
1023 let back: Receipt =
1024 serde_json::from_str(&serde_json::to_string(&receipt).expect("serializes"))
1025 .expect("a receipt reads back");
1026 assert_eq!(back.stage_root, "/tmp/s");
1027 }
1028
1029 #[test]
1032 fn every_reference_root_serves_a_file_and_nothing_else_is_served() {
1033 let files = reference_files();
1034 for root in REFERENCE_ROOTS {
1035 assert!(
1036 files
1037 .iter()
1038 .any(|(path, _)| path == root || path.starts_with(&format!("{root}/"))),
1039 "{root}: the reference tree carries no file for it"
1040 );
1041 }
1042 for (path, _) in &files {
1043 assert!(
1044 REFERENCE_ROOTS
1045 .iter()
1046 .any(|root| path == root || path.starts_with(&format!("{root}/"))),
1047 "{path}: outside every declared reference root"
1048 );
1049 assert!(
1050 !path
1051 .split('/')
1052 .any(|part| part == "_docs" || part == "tests" || part == "src"),
1053 "{path}: an instance-owned or source path in the reference tree"
1054 );
1055 }
1056 }
1057
1058 #[test]
1062 fn a_pre_existing_temp_sibling_is_never_touched() {
1063 let scratch = tempfile::tempdir().expect("a scratch dir exists");
1064 let parent = std::fs::canonicalize(scratch.path()).expect("canonical");
1065 let output = parent.join("stage");
1066 let target = parent.join("target");
1067 std::fs::create_dir(&target).expect("creates");
1068 let prepared =
1069 super::prepare(&output, super::OutputSource::Flag, &target).expect("prepares");
1070 let stranger = parent.join(super::temp_name(std::ffi::OsStr::new("stage"), 0));
1071 std::fs::create_dir_all(stranger.join("deep")).expect("creates");
1072 std::fs::write(stranger.join("deep/canary"), b"not yours").expect("writes");
1073 std::fs::write(stranger.join("canary"), b"still not yours").expect("writes");
1074 let composed = super::Composed {
1075 files: vec![(
1076 "artifacts/a.txt".to_owned(),
1077 std::borrow::Cow::Borrowed(b"a"),
1078 )],
1079 receipt: sample_receipt(),
1080 };
1081 super::write(&prepared, &composed).expect("the write lands beside the stranger");
1082 assert_eq!(
1083 std::fs::read(output.join("artifacts/a.txt")).expect("reads"),
1084 b"a"
1085 );
1086 assert_eq!(
1087 std::fs::read(stranger.join("deep/canary")).expect("the stranger reads"),
1088 b"not yours"
1089 );
1090 assert_eq!(
1091 std::fs::read(stranger.join("canary")).expect("the stranger reads"),
1092 b"still not yours"
1093 );
1094 let output_two = parent.join("stage-two");
1096 let prepared =
1097 super::prepare(&output_two, super::OutputSource::Flag, &target).expect("prepares");
1098 let stranger_two = parent.join(super::temp_name(std::ffi::OsStr::new("stage-two"), 0));
1099 std::fs::create_dir(&stranger_two).expect("creates");
1100 std::fs::write(stranger_two.join("canary"), b"kept").expect("writes");
1101 let stopped = super::write_stopping_at(
1102 &prepared,
1103 &composed,
1104 Some(std::path::Path::new("artifacts/a.txt")),
1105 );
1106 assert!(stopped.is_err());
1107 assert!(!output_two.exists());
1108 assert_eq!(
1109 std::fs::read(stranger_two.join("canary")).expect("the stranger reads"),
1110 b"kept"
1111 );
1112 let leftovers: Vec<String> = std::fs::read_dir(&parent)
1113 .expect("reads")
1114 .map(|entry| {
1115 entry
1116 .expect("an entry")
1117 .file_name()
1118 .to_string_lossy()
1119 .into_owned()
1120 })
1121 .filter(|name| name.starts_with(".rk-stage-"))
1122 .collect();
1123 assert_eq!(
1124 leftovers.len(),
1125 2,
1126 "only the two strangers remain: {leftovers:?}"
1127 );
1128 }
1129
1130 #[test]
1133 fn a_stage_root_inside_the_target_refuses_before_anything_is_created() {
1134 let scratch = tempfile::tempdir().expect("a scratch dir exists");
1135 let target = std::fs::canonicalize(scratch.path())
1136 .expect("canonical")
1137 .join("t");
1138 std::fs::create_dir(&target).expect("creates");
1139 for (output, source) in [
1140 (target.join("stage"), super::OutputSource::Flag),
1141 (
1142 target.join("deep/er/stage"),
1143 super::OutputSource::Environment,
1144 ),
1145 (
1146 target.join("state/release-kit/stages/k/v"),
1147 super::OutputSource::StateRoot,
1148 ),
1149 (target.clone(), super::OutputSource::Flag),
1150 ] {
1151 let error = super::prepare(&output, source, &target).expect_err("refuses");
1152 assert_eq!(
1153 error.reason(),
1154 crate::diagnostic::Reason::DestructiveRefusal
1155 );
1156 assert_eq!(error.exit_code(), 73);
1157 }
1158 assert_eq!(
1159 std::fs::read_dir(&target).expect("reads").count(),
1160 0,
1161 "a refusal created a component inside the target"
1162 );
1163 }
1164
1165 fn sample_receipt() -> Receipt {
1166 Receipt {
1167 schema: STAGE_SCHEMA.to_owned(),
1168 rk_version: "0.0.0".into(),
1169 target: "/tmp/t".into(),
1170 stage_root: "/tmp/s".into(),
1171 parameters: test_parameters(),
1172 capabilities: vec![],
1173 receipt_schema_version: None,
1174 candidates: vec![],
1175 omissions: vec![],
1176 collisions: vec![],
1177 retired: vec![],
1178 seeded_present: vec![],
1179 state_present: vec![],
1180 reference: vec![],
1181 }
1182 }
1183
1184 #[test]
1187 fn the_target_key_is_one_flat_digest() {
1188 let key = target_key(std::path::Path::new("/a/b"));
1189 assert_eq!(key.len(), 64);
1190 assert!(key.bytes().all(|b| b.is_ascii_hexdigit()));
1191 assert_ne!(key, target_key(std::path::Path::new("/a/c")));
1192 }
1193}