1use std::collections::BTreeMap;
13
14use camino::Utf8Path;
15use serde::{Deserialize, Serialize};
16
17use crate::atomic;
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::digest::Digest;
20use crate::error::RkError;
21use crate::landing::Kind;
22pub use crate::profile::{
23 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
24};
25
26pub const MANIFEST_PATH: &str = ".release-kit/manifest.json";
28
29pub const SCHEMA_VERSION: u64 = 9;
48
49const OLDEST_READABLE_SCHEMA: u64 = 1;
51
52const PLACEMENT_SCHEMA: u64 = 7;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
65#[serde(rename_all = "kebab-case")]
66pub enum CheckoutMode {
67 LinkedWorktree,
70 MainWorktree,
74}
75
76impl CheckoutMode {
77 #[must_use]
79 pub const fn as_str(self) -> &'static str {
80 match self {
81 Self::LinkedWorktree => "linked-worktree",
82 Self::MainWorktree => "main-worktree",
83 }
84 }
85
86 #[must_use]
88 pub const fn runbook_label(self) -> &'static str {
89 match self {
90 Self::LinkedWorktree => "worktree",
91 Self::MainWorktree => "branches",
92 }
93 }
94
95 pub fn parse(raw: &str) -> Result<Self, RkError> {
104 match raw {
105 "linked-worktree" | "worktree" => Ok(Self::LinkedWorktree),
106 "main-worktree" | "branches" => Ok(Self::MainWorktree),
107 other => Err(RkError::Usage(format!(
108 "unknown checkout mode '{other}'; the modes are: linked-worktree, main-worktree"
109 ))),
110 }
111 }
112}
113
114impl<'de> serde::Deserialize<'de> for CheckoutMode {
115 fn deserialize<D: serde::Deserializer<'de>>(reader: D) -> Result<Self, D::Error> {
116 let raw = String::deserialize(reader)?;
117 Self::parse(&raw).map_err(|error| serde::de::Error::custom(error.to_string()))
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "lowercase")]
128pub enum Style {
129 Trunk,
132 Lines,
135}
136
137impl Style {
138 #[must_use]
140 pub const fn as_str(self) -> &'static str {
141 match self {
142 Self::Trunk => "trunk",
143 Self::Lines => "lines",
144 }
145 }
146
147 pub fn parse(raw: &str) -> Result<Self, RkError> {
153 match raw {
154 "trunk" => Ok(Self::Trunk),
155 "lines" => Ok(Self::Lines),
156 other => Err(RkError::Usage(format!(
157 "unknown style '{other}'; the styles are: trunk, lines"
158 ))),
159 }
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "lowercase")]
169pub enum Provider {
170 CodeQl,
174 Semgrep,
177}
178
179impl Provider {
180 #[must_use]
182 pub const fn as_str(self) -> &'static str {
183 match self {
184 Self::CodeQl => "codeql",
185 Self::Semgrep => "semgrep",
186 }
187 }
188
189 pub fn parse(raw: &str) -> Result<Option<Self>, RkError> {
196 match raw {
197 "codeql" => Ok(Some(Self::CodeQl)),
198 "semgrep" => Ok(Some(Self::Semgrep)),
199 "off" => Ok(None),
200 other => Err(RkError::Usage(format!(
201 "unknown code scanning provider '{other}'; the providers are: codeql, semgrep, and off turns the capability off"
202 ))),
203 }
204 }
205}
206
207#[derive(Debug, Serialize, Deserialize)]
213pub struct Manifest {
214 pub schema_version: u64,
216 pub rk_version: String,
218 pub origin: String,
220 pub landed_at: String,
222 pub profile: ProfileSnapshot,
225 pub git: GitWorkflow,
228 pub capabilities: CapabilityRequests,
230 pub parameters: Parameters,
233 pub files: Vec<FileRecord>,
235 pub pins: BTreeMap<String, String>,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct Parameters {
243 #[serde(default)]
247 pub repo: String,
248 #[serde(default, deserialize_with = "read_contact")]
253 pub security_contact: String,
254 #[serde(default = "response_best_effort", deserialize_with = "read_response")]
258 pub security_response: String,
259}
260
261fn response_best_effort() -> String {
263 crate::config::RESPONSE_DEFAULT.to_owned()
264}
265
266fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
272 canonical(reader, "security_contact", crate::config::canonical_contact)
273}
274
275fn read_response<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
277 canonical(
278 reader,
279 "security_response",
280 crate::config::canonical_response,
281 )
282}
283
284fn canonical<'de, D: serde::Deserializer<'de>>(
286 reader: D,
287 field: &str,
288 judge: impl Fn(&str) -> Result<String, String>,
289) -> Result<String, D::Error> {
290 let raw = String::deserialize(reader)?;
291 let canonical = judge(&raw)
292 .map_err(|reason| serde::de::Error::custom(format!("parameters.{field}: {reason}")))?;
293 if canonical == raw {
294 Ok(canonical)
295 } else {
296 Err(serde::de::Error::custom(format!(
297 "parameters.{field} is not canonical: the record carries {raw:?} where a landing writes {canonical:?}"
298 )))
299 }
300}
301
302#[derive(Debug, Serialize, Deserialize)]
304pub struct FileRecord {
305 pub destination: String,
307 pub kind: Kind,
309 pub sha256: Digest,
312 #[serde(default, skip_serializing_if = "Placement::is_whole")]
316 pub placement: Placement,
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
321#[serde(rename_all = "lowercase")]
322pub enum Placement {
323 #[default]
325 Whole,
326 Region,
329}
330
331impl Placement {
332 #[must_use]
334 pub const fn is_whole(&self) -> bool {
335 matches!(self, Self::Whole)
336 }
337
338 #[must_use]
340 pub const fn as_str(self) -> &'static str {
341 match self {
342 Self::Whole => "whole",
343 Self::Region => "region",
344 }
345 }
346}
347
348pub mod legacy {
358 use serde_json::{Map, Value, json};
359
360 fn take(object: &mut Map<String, Value>, key: &str) -> Option<Value> {
362 object.remove(key)
363 }
364
365 #[must_use]
376 pub fn convert(mut value: Value) -> Value {
377 let Some(record) = value.as_object_mut() else {
378 return value;
379 };
380 record.remove("payload_sha256");
381 if let Some(files) = record.get_mut("files").and_then(Value::as_array_mut) {
382 for file in files.iter_mut().filter_map(Value::as_object_mut) {
383 file.remove("baseline_sha256");
384 }
385 }
386 if record.contains_key("profile") {
387 return value;
388 }
389 let tech = take(record, "tech").and_then(|v| v.as_str().map(str::to_owned));
390 let forge = take(record, "forge").and_then(|v| v.as_str().map(str::to_owned));
391 let mut parameters = take(record, "parameters")
392 .and_then(|v| v.as_object().cloned())
393 .unwrap_or_default();
394 parameters.remove("scopes");
395 let checkout_mode = match parameters
396 .remove("workflow")
397 .and_then(|v| v.as_str().map(str::to_owned))
398 .as_deref()
399 {
400 Some("worktree") => "linked-worktree",
401 _ => "main-worktree",
404 };
405 let style = parameters.remove("style").unwrap_or(Value::Null);
406 let trunk = parameters
407 .remove("trunk")
408 .unwrap_or_else(|| json!(crate::config::TRUNK_DEFAULT));
409 let line_prefix = parameters
410 .remove("line_prefix")
411 .unwrap_or_else(|| json!(crate::config::LINE_PREFIX_DEFAULT));
412 let nix = parameters.remove("nix").unwrap_or(json!(false));
413 let scorecard = parameters.remove("scorecard").unwrap_or(json!(false));
414 let code_scanning = parameters.remove("code_scanning").unwrap_or(Value::Null);
415 let mut release = Map::new();
416 release.insert("mode".into(), json!("automatic"));
417 if let Some(tech) = &tech {
418 release.insert("driver".into(), json!(tech));
419 }
420 if !style.is_null() {
421 release.insert("style".into(), style);
422 }
423 release.insert("line_prefix".into(), line_prefix);
424 let technologies: Vec<Value> = tech.iter().map(|t| json!(t)).collect();
425 record.insert(
426 "profile".into(),
427 json!({
428 "technologies": technologies,
429 "forge": forge,
430 "release": Value::Object(release),
431 }),
432 );
433 record.insert(
434 "git".into(),
435 json!({ "trunk": trunk, "checkout_mode": checkout_mode }),
436 );
437 record.insert(
438 "capabilities".into(),
439 json!({
440 "nix_packaging": nix,
441 "reporting_policy": true,
442 "scorecard": scorecard,
443 "code_scanning": code_scanning,
444 }),
445 );
446 record.insert("parameters".into(), Value::Object(parameters));
447 value
448 }
449}
450
451impl Manifest {
452 #[must_use]
454 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
455 self.files
456 .iter()
457 .find(|file| file.destination == destination)
458 }
459}
460
461pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
470 let path = target.join(MANIFEST_PATH);
471 let bytes = match std::fs::read(&path) {
472 Ok(bytes) => bytes,
473 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
474 Err(e) => {
475 return Err(RkError::refusal(
476 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
477 .expected("a readable landing record")
478 .target_state("unchanged"),
479 ));
480 }
481 };
482 let value: serde_json::Value = serde_json::from_slice(&bytes)
483 .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
484 let schema = value
489 .get("schema_version")
490 .and_then(serde_json::Value::as_u64);
491 if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
492 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
493 return Err(RkError::refusal(
494 Diagnostic::new(
495 Reason::UnsupportedSchema,
496 format!(
497 "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
498 ),
499 )
500 .expected("a landing record at a schema this binary knows")
501 .action("install the rk release that wrote this record, or a newer one")
502 .target_state("unchanged"),
503 ));
504 }
505 let declared = schema.unwrap_or(SCHEMA_VERSION);
506 let value = if declared < SCHEMA_VERSION {
507 legacy::convert(value)
508 } else {
509 value
510 };
511 let mut manifest: Manifest = serde_json::from_value(value)
512 .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version {declared}: {e}"))?;
513 for file in &mut manifest.files {
517 if declared < PLACEMENT_SCHEMA && crate::landing::block_markers(&file.destination).is_some()
518 {
519 file.placement = Placement::Region;
520 }
521 }
522 Ok(Some(manifest))
523}
524
525pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
531 let path = target.join(MANIFEST_PATH);
532 atomic::write(path.as_std_path(), &render(manifest)?)?;
533 Ok(())
534}
535
536pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
542 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
543 Ok(format!("{text}\n").into_bytes())
544}
545
546#[must_use]
548pub fn now() -> String {
549 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
554#[serde(rename_all = "kebab-case")]
555pub enum Alignment {
556 Aligned,
558 BinaryNewer,
560 TargetNewer,
563}
564
565impl Alignment {
566 #[must_use]
568 pub const fn as_str(self) -> &'static str {
569 match self {
570 Self::Aligned => "aligned",
571 Self::BinaryNewer => "binary-newer",
572 Self::TargetNewer => "target-newer",
573 }
574 }
575}
576
577#[must_use]
579pub fn alignment(recorded: &str, binary: &str) -> Alignment {
580 let recorded = recorded
582 .split_once('+')
583 .map_or(recorded, |(version, _)| version);
584 let binary = binary
585 .split_once('+')
586 .map_or(binary, |(version, _)| version);
587 let recorded_core = numeric_core(recorded);
588 let binary_core = numeric_core(binary);
589 match binary_core.cmp(&recorded_core) {
590 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
591 std::cmp::Ordering::Less => Alignment::TargetNewer,
592 std::cmp::Ordering::Equal => {
593 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
598 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
599 match (recorded_pre, binary_pre) {
600 (Some(_), None) => Alignment::BinaryNewer,
601 (None, Some(_)) => Alignment::TargetNewer,
602 (None, None) => Alignment::Aligned,
603 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
604 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
605 std::cmp::Ordering::Less => Alignment::TargetNewer,
606 std::cmp::Ordering::Equal => Alignment::Aligned,
607 },
608 }
609 }
610 }
611}
612
613#[must_use]
616pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
617 alignment(pinned, candidate) == Alignment::BinaryNewer
618}
619
620fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
627 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
628 let mut left = a.split('.');
629 let mut right = b.split('.');
630 loop {
631 match (left.next(), right.next()) {
632 (None, None) => return std::cmp::Ordering::Equal,
633 (None, Some(_)) => return std::cmp::Ordering::Less,
634 (Some(_), None) => return std::cmp::Ordering::Greater,
635 (Some(x), Some(y)) => {
636 let ordering = match (numeric(x), numeric(y)) {
637 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
638 (true, false) => std::cmp::Ordering::Less,
639 (false, true) => std::cmp::Ordering::Greater,
640 (false, false) => x.cmp(y),
641 };
642 if ordering != std::cmp::Ordering::Equal {
643 return ordering;
644 }
645 }
646 }
647 }
648}
649
650fn numeric_core(version: &str) -> Vec<u64> {
652 let core = version.split_once('-').map_or(version, |(core, _)| core);
653 core.split('.')
654 .map(|part| part.parse::<u64>().unwrap_or(0))
655 .collect()
656}
657
658#[cfg(test)]
659mod tests {
660 use super::{
661 Alignment, CapabilityRequests, CheckoutMode, FileRecord, GitWorkflow, Manifest, Parameters,
662 Placement, ProfileSnapshot, Provider, ReleaseIntent, ReleaseMode, Style, alignment,
663 };
664 use crate::digest::Digest;
665 use crate::landing::Kind;
666
667 #[test]
671 fn the_manifest_schema_snapshot_holds() {
672 let manifest = Manifest {
673 schema_version: 9,
674 rk_version: "0.1.0".into(),
675 origin: "init".into(),
676 landed_at: "2026-08-29T00:00:00Z".into(),
677 profile: ProfileSnapshot {
678 technologies: vec!["rust".into()],
679 forge: Some("github".into()),
680 release: ReleaseIntent {
681 mode: ReleaseMode::Automatic,
682 driver: Some("rust".into()),
683 style: Some(Style::Trunk),
684 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
685 },
686 },
687 git: GitWorkflow {
688 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
689 checkout_mode: CheckoutMode::LinkedWorktree,
690 },
691 capabilities: CapabilityRequests {
692 nix_packaging: true,
693 reporting_policy: true,
694 scorecard: true,
695 code_scanning: Some(Provider::Semgrep),
696 },
697 parameters: Parameters {
698 repo: "acme/widget".into(),
699 security_contact: String::new(),
700 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
701 },
702 files: vec![
703 FileRecord {
704 destination: "release-plz.toml".into(),
705 kind: Kind::Seeded,
706 sha256: Digest::of(b""),
707 placement: Placement::Whole,
708 },
709 FileRecord {
710 destination: "AGENTS.md".into(),
711 kind: Kind::Rendered,
712 sha256: Digest::of(b""),
713 placement: Placement::Region,
714 },
715 ],
716 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
717 };
718 let empty = Digest::of(b"").to_string();
719 let text = serde_json::to_string(&manifest).expect("a manifest serializes");
720 assert_eq!(
721 text,
722 format!(
723 r#"{{"schema_version":9,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{{"technologies":["rust"],"forge":"github","release":{{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}}}},"git":{{"trunk":"master","checkout_mode":"linked-worktree"}},"capabilities":{{"nix_packaging":true,"reporting_policy":true,"scorecard":true,"code_scanning":"semgrep"}},"parameters":{{"repo":"acme/widget","security_contact":"","security_response":"best-effort"}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}"}},{{"destination":"AGENTS.md","kind":"rendered","sha256":"{empty}","placement":"region"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
724 ),
725 "a whole file omits its placement, and no retired digest field survives"
726 );
727 assert!(!text.contains("payload_sha256") && !text.contains("baseline_sha256"));
728 let release_less = Manifest {
730 profile: ProfileSnapshot {
731 technologies: vec![],
732 forge: None,
733 release: ReleaseIntent {
734 mode: ReleaseMode::None,
735 driver: None,
736 style: None,
737 line_prefix: None,
738 },
739 },
740 capabilities: CapabilityRequests {
741 nix_packaging: false,
742 reporting_policy: false,
743 scorecard: false,
744 code_scanning: None,
745 },
746 parameters: Parameters {
747 repo: String::new(),
748 security_contact: String::new(),
749 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
750 },
751 files: vec![],
752 pins: std::collections::BTreeMap::new(),
753 ..manifest
754 };
755 assert_eq!(
756 serde_json::to_string(&release_less).expect("serializes"),
757 r#"{"schema_version":9,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{"technologies":[],"release":{"mode":"none"}},"git":{"trunk":"master","checkout_mode":"linked-worktree"},"capabilities":{"nix_packaging":false,"reporting_policy":false,"scorecard":false},"parameters":{"repo":"","security_contact":"","security_response":"best-effort"},"files":[],"pins":{}}"#
758 );
759 }
760
761 #[test]
769 fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
770 let dir = tempfile::tempdir().expect("a scratch target exists");
771 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
772 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
773 let record = |schema: u64| {
774 format!(
775 r#"{{"schema_version":{schema},"rk_version":"0.1.0","payload_sha256":"0000000000000000000000000000000000000000000000000000000000000000","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","scopes":["api"]}},"files":[],"pins":{{}}}}"#
776 )
777 };
778 std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
779 let manifest = super::load(target)
780 .expect("a schema-1 record loads")
781 .expect("the record exists");
782 assert_eq!(manifest.git.checkout_mode, CheckoutMode::MainWorktree);
783 assert_eq!(manifest.profile.technologies, vec!["rust".to_owned()]);
784 assert_eq!(manifest.profile.forge.as_deref(), Some("github"));
785 assert_eq!(manifest.profile.release.mode, ReleaseMode::Automatic);
786 assert_eq!(manifest.profile.release.driver.as_deref(), Some("rust"));
787 assert_eq!(
788 manifest.profile.release.style, None,
789 "a pre-style record carries no style; the upgrade demands one"
790 );
791 assert_eq!(
792 manifest.profile.release.line_prefix.as_deref(),
793 Some(crate::config::LINE_PREFIX_DEFAULT)
794 );
795 assert_eq!(manifest.git.trunk, crate::config::TRUNK_DEFAULT);
796 assert!(
797 !manifest.capabilities.nix_packaging,
798 "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
799 );
800 assert!(
801 manifest.capabilities.reporting_policy,
802 "an older landing carried the policy, so the record says so"
803 );
804 assert_eq!(
805 manifest.parameters.security_contact, "",
806 "a pre-policy record names no contact, which is what its policy landed"
807 );
808 assert_eq!(
809 manifest.parameters.security_response,
810 crate::config::RESPONSE_DEFAULT,
811 "a pre-policy record promises no window, which is what its policy landed"
812 );
813
814 for schema in 2..=8 {
815 std::fs::write(
816 target.join(super::MANIFEST_PATH),
817 format!(
818 r#"{{"schema_version":{schema},"rk_version":"0.1.0","payload_sha256":"0000000000000000000000000000000000000000000000000000000000000000","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","workflow":"worktree","style":"lines","nix":true,"scorecard":true,"code_scanning":"semgrep","trunk":"main","line_prefix":"stable/"}},"files":[{{"destination":"AGENTS.md","kind":"rendered","sha256":"0000000000000000000000000000000000000000000000000000000000000000","baseline_sha256":"0000000000000000000000000000000000000000000000000000000000000000"}}],"pins":{{}}}}"#
819 ),
820 )
821 .expect("the record writes");
822 let manifest = super::load(target)
823 .expect("an earlier record loads")
824 .expect("the record exists");
825 assert_eq!(manifest.schema_version, schema);
826 assert_eq!(manifest.git.checkout_mode, CheckoutMode::LinkedWorktree);
827 assert_eq!(manifest.git.trunk, "main");
831 assert_eq!(manifest.profile.release.style, Some(Style::Lines));
832 assert_eq!(
833 manifest.profile.release.line_prefix.as_deref(),
834 Some("stable/")
835 );
836 assert!(manifest.capabilities.nix_packaging && manifest.capabilities.scorecard);
837 assert_eq!(manifest.capabilities.code_scanning, Some(Provider::Semgrep));
838 assert_eq!(
839 manifest.files[0].placement,
840 if schema < super::PLACEMENT_SCHEMA {
841 Placement::Region
842 } else {
843 Placement::Whole
844 },
845 "a block destination below the placement schema reads as a region"
846 );
847 let rewritten = super::render(&manifest).expect("renders");
848 let text = String::from_utf8(rewritten).expect("text");
849 assert!(!text.contains("baseline_sha256"), "{text}");
850 assert!(
851 !text.contains("\"tech\""),
852 "the flat identity moved: {text}"
853 );
854 }
855
856 std::fs::write(target.join(super::MANIFEST_PATH), record(999)).expect("the record writes");
857 let refused = super::load(target).expect_err("a schema-999 record refuses");
858 assert_eq!(
859 refused.reason(),
860 crate::diagnostic::Reason::UnsupportedSchema
861 );
862 let message = refused.to_string();
863 assert!(message.contains("999"), "{message}");
864 assert!(message.contains(super::MANIFEST_PATH), "{message}");
865 assert!(
866 !message.to_lowercase().contains("bundle"),
867 "the record schema stands alone: {message}"
868 );
869 }
870
871 #[test]
876 fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
877 let dir = tempfile::tempdir().expect("a scratch target exists");
878 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
879 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
880 for (field, value) in [
881 ("security_contact", "team@acme.example\\nsecond line"),
884 ("security_contact", " team@acme.example "),
885 ("security_response", "90d"),
886 ("security_response", "0 days"),
887 ("security_response", "07 days"),
888 ("security_response", "1 days"),
889 ("security_response", ""),
890 ] {
891 let record = format!(
892 r#"{{"schema_version":9,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{{"technologies":["rust"],"forge":"github","release":{{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}}}},"git":{{"trunk":"master","checkout_mode":"linked-worktree"}},"capabilities":{{"nix_packaging":false,"reporting_policy":true,"scorecard":false}},"parameters":{{"repo":"acme/widget","{field}":"{value}"}},"files":[],"pins":{{}}}}"#
893 );
894 std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
895 let refused = super::load(target).expect_err("an uncanonical record refuses");
896 assert!(refused.to_string().contains(field), "{field}: {refused}");
897 }
898 }
899
900 #[test]
901 fn alignment_orders_versions_numerically() {
902 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
903 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
904 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
905 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
906 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
907 }
908
909 #[test]
914 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
915 assert_eq!(
916 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
917 Alignment::TargetNewer
918 );
919 assert_eq!(
920 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
921 Alignment::BinaryNewer
922 );
923 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
924 assert_eq!(
925 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
926 Alignment::BinaryNewer
927 );
928 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
929 assert_eq!(
930 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
931 Alignment::TargetNewer,
932 "identifiers past the u64 range still compare numerically"
933 );
934 assert_eq!(
935 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
936 Alignment::BinaryNewer
937 );
938 }
939
940 #[test]
943 fn alignment_ignores_build_metadata() {
944 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
945 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
946 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
947 assert_eq!(
948 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
949 Alignment::Aligned
950 );
951 assert_eq!(
952 alignment("1.2.10-rc.1+build", "1.2.10"),
953 Alignment::BinaryNewer
954 );
955 }
956}