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;
22
23pub const MANIFEST_PATH: &str = ".release-kit/manifest.json";
25
26pub const SCHEMA_VERSION: u64 = 8;
44
45const OLDEST_READABLE_SCHEMA: u64 = 1;
47
48const PLACEMENT_SCHEMA: u64 = 7;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum Workflow {
58 Worktree,
61 Branches,
64}
65
66impl Workflow {
67 #[must_use]
69 pub const fn as_str(self) -> &'static str {
70 match self {
71 Self::Worktree => "worktree",
72 Self::Branches => "branches",
73 }
74 }
75
76 pub fn parse(raw: &str) -> Result<Self, RkError> {
82 match raw {
83 "worktree" => Ok(Self::Worktree),
84 "branches" => Ok(Self::Branches),
85 other => Err(RkError::Usage(format!(
86 "unknown workflow '{other}'; the modes are: worktree, branches"
87 ))),
88 }
89 }
90}
91
92const fn workflow_branches() -> Workflow {
94 Workflow::Branches
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum Style {
105 Trunk,
108 Lines,
111}
112
113impl Style {
114 #[must_use]
116 pub const fn as_str(self) -> &'static str {
117 match self {
118 Self::Trunk => "trunk",
119 Self::Lines => "lines",
120 }
121 }
122
123 pub fn parse(raw: &str) -> Result<Self, RkError> {
129 match raw {
130 "trunk" => Ok(Self::Trunk),
131 "lines" => Ok(Self::Lines),
132 other => Err(RkError::Usage(format!(
133 "unknown style '{other}'; the styles are: trunk, lines"
134 ))),
135 }
136 }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "lowercase")]
145pub enum Provider {
146 CodeQl,
150 Semgrep,
153}
154
155impl Provider {
156 #[must_use]
158 pub const fn as_str(self) -> &'static str {
159 match self {
160 Self::CodeQl => "codeql",
161 Self::Semgrep => "semgrep",
162 }
163 }
164
165 pub fn parse(raw: &str) -> Result<Option<Self>, RkError> {
172 match raw {
173 "codeql" => Ok(Some(Self::CodeQl)),
174 "semgrep" => Ok(Some(Self::Semgrep)),
175 "off" => Ok(None),
176 other => Err(RkError::Usage(format!(
177 "unknown code scanning provider '{other}'; the providers are: codeql, semgrep, and off turns the capability off"
178 ))),
179 }
180 }
181}
182
183#[derive(Debug, Serialize, Deserialize)]
185pub struct Manifest {
186 pub schema_version: u64,
188 pub rk_version: String,
190 pub origin: String,
192 pub tech: String,
194 pub forge: String,
196 pub landed_at: String,
198 pub parameters: Parameters,
201 pub files: Vec<FileRecord>,
203 pub pins: BTreeMap<String, String>,
206}
207
208#[derive(Debug, Serialize, Deserialize)]
210pub struct Parameters {
211 pub repo: String,
214 #[serde(default = "workflow_branches")]
220 pub workflow: Workflow,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub style: Option<Style>,
228 #[serde(default)]
235 pub nix: bool,
236 #[serde(default)]
242 pub scorecard: bool,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub code_scanning: Option<Provider>,
248 #[serde(default = "trunk_master")]
252 pub trunk: String,
253 #[serde(default = "line_prefix_release")]
257 pub line_prefix: String,
258 #[serde(default, deserialize_with = "read_contact")]
263 pub security_contact: String,
264 #[serde(default = "response_best_effort", deserialize_with = "read_response")]
268 pub security_response: String,
269}
270
271fn trunk_master() -> String {
273 crate::config::TRUNK_DEFAULT.to_owned()
274}
275
276fn line_prefix_release() -> String {
278 crate::config::LINE_PREFIX_DEFAULT.to_owned()
279}
280
281fn response_best_effort() -> String {
283 crate::config::RESPONSE_DEFAULT.to_owned()
284}
285
286fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
292 canonical(reader, "security_contact", crate::config::canonical_contact)
293}
294
295fn read_response<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
297 canonical(
298 reader,
299 "security_response",
300 crate::config::canonical_response,
301 )
302}
303
304fn canonical<'de, D: serde::Deserializer<'de>>(
306 reader: D,
307 field: &str,
308 judge: impl Fn(&str) -> Result<String, String>,
309) -> Result<String, D::Error> {
310 let raw = String::deserialize(reader)?;
311 let canonical = judge(&raw)
312 .map_err(|reason| serde::de::Error::custom(format!("parameters.{field}: {reason}")))?;
313 if canonical == raw {
314 Ok(canonical)
315 } else {
316 Err(serde::de::Error::custom(format!(
317 "parameters.{field} is not canonical: the record carries {raw:?} where a landing writes {canonical:?}"
318 )))
319 }
320}
321
322#[derive(Debug, Serialize, Deserialize)]
324pub struct FileRecord {
325 pub destination: String,
327 pub kind: Kind,
329 pub sha256: Digest,
332 #[serde(default, skip_serializing_if = "Placement::is_whole")]
336 pub placement: Placement,
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
341#[serde(rename_all = "lowercase")]
342pub enum Placement {
343 #[default]
345 Whole,
346 Region,
349}
350
351impl Placement {
352 #[must_use]
354 pub const fn is_whole(&self) -> bool {
355 matches!(self, Self::Whole)
356 }
357
358 #[must_use]
360 pub const fn as_str(self) -> &'static str {
361 match self {
362 Self::Whole => "whole",
363 Self::Region => "region",
364 }
365 }
366}
367
368pub mod legacy {
375 pub fn convert(mut value: serde_json::Value) -> serde_json::Value {
383 if let Some(record) = value.as_object_mut() {
384 record.remove("payload_sha256");
385 if let Some(parameters) = record
386 .get_mut("parameters")
387 .and_then(serde_json::Value::as_object_mut)
388 {
389 parameters.remove("scopes");
390 }
391 if let Some(files) = record
392 .get_mut("files")
393 .and_then(serde_json::Value::as_array_mut)
394 {
395 for file in files
396 .iter_mut()
397 .filter_map(serde_json::Value::as_object_mut)
398 {
399 file.remove("baseline_sha256");
400 }
401 }
402 }
403 value
404 }
405}
406
407impl Manifest {
408 #[must_use]
410 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
411 self.files
412 .iter()
413 .find(|file| file.destination == destination)
414 }
415}
416
417pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
426 let path = target.join(MANIFEST_PATH);
427 let bytes = match std::fs::read(&path) {
428 Ok(bytes) => bytes,
429 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
430 Err(e) => {
431 return Err(RkError::refusal(
432 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
433 .expected("a readable landing record")
434 .target_state("unchanged"),
435 ));
436 }
437 };
438 let value: serde_json::Value = serde_json::from_slice(&bytes)
439 .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
440 let schema = value
445 .get("schema_version")
446 .and_then(serde_json::Value::as_u64);
447 if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
448 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
449 return Err(RkError::refusal(
450 Diagnostic::new(
451 Reason::UnsupportedSchema,
452 format!(
453 "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
454 ),
455 )
456 .expected("a landing record at a schema this binary knows")
457 .action("install the rk release that wrote this record, or a newer one")
458 .target_state("unchanged"),
459 ));
460 }
461 let declared = schema.unwrap_or(SCHEMA_VERSION);
462 let value = if declared < SCHEMA_VERSION {
463 legacy::convert(value)
464 } else {
465 value
466 };
467 let mut manifest: Manifest = serde_json::from_value(value)
468 .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version {declared}: {e}"))?;
469 for file in &mut manifest.files {
473 if declared < PLACEMENT_SCHEMA && crate::landing::block_markers(&file.destination).is_some()
474 {
475 file.placement = Placement::Region;
476 }
477 }
478 Ok(Some(manifest))
479}
480
481pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
487 let path = target.join(MANIFEST_PATH);
488 atomic::write(path.as_std_path(), &render(manifest)?)?;
489 Ok(())
490}
491
492pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
498 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
499 Ok(format!("{text}\n").into_bytes())
500}
501
502#[must_use]
504pub fn now() -> String {
505 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
510#[serde(rename_all = "kebab-case")]
511pub enum Alignment {
512 Aligned,
514 BinaryNewer,
516 TargetNewer,
519}
520
521impl Alignment {
522 #[must_use]
524 pub const fn as_str(self) -> &'static str {
525 match self {
526 Self::Aligned => "aligned",
527 Self::BinaryNewer => "binary-newer",
528 Self::TargetNewer => "target-newer",
529 }
530 }
531}
532
533#[must_use]
535pub fn alignment(recorded: &str, binary: &str) -> Alignment {
536 let recorded = recorded
538 .split_once('+')
539 .map_or(recorded, |(version, _)| version);
540 let binary = binary
541 .split_once('+')
542 .map_or(binary, |(version, _)| version);
543 let recorded_core = numeric_core(recorded);
544 let binary_core = numeric_core(binary);
545 match binary_core.cmp(&recorded_core) {
546 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
547 std::cmp::Ordering::Less => Alignment::TargetNewer,
548 std::cmp::Ordering::Equal => {
549 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
554 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
555 match (recorded_pre, binary_pre) {
556 (Some(_), None) => Alignment::BinaryNewer,
557 (None, Some(_)) => Alignment::TargetNewer,
558 (None, None) => Alignment::Aligned,
559 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
560 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
561 std::cmp::Ordering::Less => Alignment::TargetNewer,
562 std::cmp::Ordering::Equal => Alignment::Aligned,
563 },
564 }
565 }
566 }
567}
568
569#[must_use]
572pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
573 alignment(pinned, candidate) == Alignment::BinaryNewer
574}
575
576fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
583 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
584 let mut left = a.split('.');
585 let mut right = b.split('.');
586 loop {
587 match (left.next(), right.next()) {
588 (None, None) => return std::cmp::Ordering::Equal,
589 (None, Some(_)) => return std::cmp::Ordering::Less,
590 (Some(_), None) => return std::cmp::Ordering::Greater,
591 (Some(x), Some(y)) => {
592 let ordering = match (numeric(x), numeric(y)) {
593 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
594 (true, false) => std::cmp::Ordering::Less,
595 (false, true) => std::cmp::Ordering::Greater,
596 (false, false) => x.cmp(y),
597 };
598 if ordering != std::cmp::Ordering::Equal {
599 return ordering;
600 }
601 }
602 }
603 }
604}
605
606fn numeric_core(version: &str) -> Vec<u64> {
608 let core = version.split_once('-').map_or(version, |(core, _)| core);
609 core.split('.')
610 .map(|part| part.parse::<u64>().unwrap_or(0))
611 .collect()
612}
613
614#[cfg(test)]
615mod tests {
616 use super::{
617 Alignment, FileRecord, Manifest, Parameters, Placement, Provider, Style, Workflow,
618 alignment,
619 };
620 use crate::digest::Digest;
621 use crate::landing::Kind;
622
623 #[test]
627 fn the_manifest_schema_snapshot_holds() {
628 let manifest = Manifest {
629 schema_version: 8,
630 rk_version: "0.1.0".into(),
631 origin: "init".into(),
632 tech: "rust".into(),
633 forge: "github".into(),
634 landed_at: "2026-08-29T00:00:00Z".into(),
635 parameters: Parameters {
636 repo: "acme/widget".into(),
637 workflow: Workflow::Worktree,
638 style: Some(Style::Trunk),
639 nix: true,
640 scorecard: true,
641 code_scanning: Some(Provider::Semgrep),
642 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
643 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
644 security_contact: String::new(),
645 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
646 },
647 files: vec![
648 FileRecord {
649 destination: "release-plz.toml".into(),
650 kind: Kind::Seeded,
651 sha256: Digest::of(b""),
652 placement: Placement::Whole,
653 },
654 FileRecord {
655 destination: "AGENTS.md".into(),
656 kind: Kind::Rendered,
657 sha256: Digest::of(b""),
658 placement: Placement::Region,
659 },
660 ],
661 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
662 };
663 let empty = Digest::of(b"").to_string();
664 let text = serde_json::to_string(&manifest).expect("a manifest serializes");
665 assert_eq!(
666 text,
667 format!(
668 r#"{{"schema_version":8,"rk_version":"0.1.0","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","workflow":"worktree","style":"trunk","nix":true,"scorecard":true,"code_scanning":"semgrep","trunk":"master","line_prefix":"release/","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"}}}}"#
669 ),
670 "a whole file omits its placement, and no retired digest field survives"
671 );
672 assert!(!text.contains("payload_sha256") && !text.contains("baseline_sha256"));
673 }
674
675 #[test]
681 fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
682 let dir = tempfile::tempdir().expect("a scratch target exists");
683 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
684 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
685 let record = |schema: u64| {
686 format!(
687 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":{{}}}}"#
688 )
689 };
690 std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
691 let manifest = super::load(target)
692 .expect("a schema-1 record loads")
693 .expect("the record exists");
694 assert_eq!(manifest.parameters.workflow, Workflow::Branches);
695 assert_eq!(
696 manifest.parameters.style, None,
697 "a pre-style record carries no style; the upgrade demands one"
698 );
699 assert!(
700 !manifest.parameters.nix,
701 "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
702 );
703 assert_eq!(
704 manifest.parameters.security_contact, "",
705 "a pre-policy record names no contact, which is what its policy landed"
706 );
707 assert_eq!(
708 manifest.parameters.security_response,
709 crate::config::RESPONSE_DEFAULT,
710 "a pre-policy record promises no window, which is what its policy landed"
711 );
712
713 for schema in 2..=6 {
714 std::fs::write(
715 target.join(super::MANIFEST_PATH),
716 format!(
717 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"}},"files":[{{"destination":"AGENTS.md","kind":"rendered","sha256":"0000000000000000000000000000000000000000000000000000000000000000","baseline_sha256":"0000000000000000000000000000000000000000000000000000000000000000"}}],"pins":{{}}}}"#
718 ),
719 )
720 .expect("the record writes");
721 let manifest = super::load(target)
722 .expect("an earlier record loads")
723 .expect("the record exists");
724 assert_eq!(manifest.schema_version, schema);
725 assert_eq!(
726 manifest.files[0].placement,
727 Placement::Region,
728 "a block destination reads as a region"
729 );
730 let rewritten = super::render(&manifest).expect("renders");
731 let text = String::from_utf8(rewritten).expect("text");
732 assert!(!text.contains("baseline_sha256"), "{text}");
733 }
734
735 std::fs::write(target.join(super::MANIFEST_PATH), record(999)).expect("the record writes");
736 let refused = super::load(target).expect_err("a schema-999 record refuses");
737 assert_eq!(
738 refused.reason(),
739 crate::diagnostic::Reason::UnsupportedSchema
740 );
741 let message = refused.to_string();
742 assert!(message.contains("999"), "{message}");
743 assert!(message.contains(super::MANIFEST_PATH), "{message}");
744 assert!(
745 !message.to_lowercase().contains("bundle"),
746 "the record schema stands alone: {message}"
747 );
748 }
749
750 #[test]
755 fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
756 let dir = tempfile::tempdir().expect("a scratch target exists");
757 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
758 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
759 for (field, value) in [
760 ("security_contact", "team@acme.example\\nsecond line"),
763 ("security_contact", " team@acme.example "),
764 ("security_response", "90d"),
765 ("security_response", "0 days"),
766 ("security_response", "07 days"),
767 ("security_response", "1 days"),
768 ("security_response", ""),
769 ] {
770 let record = format!(
771 r#"{{"schema_version":8,"rk_version":"0.1.0","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","{field}":"{value}"}},"files":[],"pins":{{}}}}"#
772 );
773 std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
774 let refused = super::load(target).expect_err("an uncanonical record refuses");
775 assert!(refused.to_string().contains(field), "{field}: {refused}");
776 }
777 }
778
779 #[test]
780 fn alignment_orders_versions_numerically() {
781 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
782 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
783 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
784 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
785 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
786 }
787
788 #[test]
793 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
794 assert_eq!(
795 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
796 Alignment::TargetNewer
797 );
798 assert_eq!(
799 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
800 Alignment::BinaryNewer
801 );
802 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
803 assert_eq!(
804 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
805 Alignment::BinaryNewer
806 );
807 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
808 assert_eq!(
809 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
810 Alignment::TargetNewer,
811 "identifiers past the u64 range still compare numerically"
812 );
813 assert_eq!(
814 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
815 Alignment::BinaryNewer
816 );
817 }
818
819 #[test]
822 fn alignment_ignores_build_metadata() {
823 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
824 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
825 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
826 assert_eq!(
827 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
828 Alignment::Aligned
829 );
830 assert_eq!(
831 alignment("1.2.10-rc.1+build", "1.2.10"),
832 Alignment::BinaryNewer
833 );
834 }
835}