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