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