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 = 7;
44
45const OLDEST_READABLE_SCHEMA: u64 = 1;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum Workflow {
53 Worktree,
56 Branches,
59}
60
61impl Workflow {
62 #[must_use]
64 pub const fn as_str(self) -> &'static str {
65 match self {
66 Self::Worktree => "worktree",
67 Self::Branches => "branches",
68 }
69 }
70
71 pub fn parse(raw: &str) -> Result<Self, RkError> {
77 match raw {
78 "worktree" => Ok(Self::Worktree),
79 "branches" => Ok(Self::Branches),
80 other => Err(RkError::Usage(format!(
81 "unknown workflow '{other}'; the modes are: worktree, branches"
82 ))),
83 }
84 }
85}
86
87const fn workflow_branches() -> Workflow {
89 Workflow::Branches
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum Style {
100 Trunk,
103 Lines,
106}
107
108impl Style {
109 #[must_use]
111 pub const fn as_str(self) -> &'static str {
112 match self {
113 Self::Trunk => "trunk",
114 Self::Lines => "lines",
115 }
116 }
117
118 pub fn parse(raw: &str) -> Result<Self, RkError> {
124 match raw {
125 "trunk" => Ok(Self::Trunk),
126 "lines" => Ok(Self::Lines),
127 other => Err(RkError::Usage(format!(
128 "unknown style '{other}'; the styles are: trunk, lines"
129 ))),
130 }
131 }
132}
133
134#[derive(Debug, Serialize, Deserialize)]
136pub struct Manifest {
137 pub schema_version: u64,
139 pub rk_version: String,
141 pub origin: String,
143 pub tech: String,
145 pub forge: String,
147 pub landed_at: String,
149 pub parameters: Parameters,
152 pub files: Vec<FileRecord>,
154 pub pins: BTreeMap<String, String>,
157}
158
159#[derive(Debug, Serialize, Deserialize)]
161pub struct Parameters {
162 pub repo: String,
165 #[serde(default = "workflow_branches")]
171 pub workflow: Workflow,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub style: Option<Style>,
179 #[serde(default)]
186 pub nix: bool,
187 #[serde(default = "trunk_master")]
191 pub trunk: String,
192 #[serde(default = "line_prefix_release")]
196 pub line_prefix: String,
197 #[serde(default, deserialize_with = "read_contact")]
202 pub security_contact: String,
203 #[serde(default = "response_best_effort", deserialize_with = "read_response")]
207 pub security_response: String,
208}
209
210fn trunk_master() -> String {
212 crate::config::TRUNK_DEFAULT.to_owned()
213}
214
215fn line_prefix_release() -> String {
217 crate::config::LINE_PREFIX_DEFAULT.to_owned()
218}
219
220fn response_best_effort() -> String {
222 crate::config::RESPONSE_DEFAULT.to_owned()
223}
224
225fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
231 canonical(reader, "security_contact", crate::config::canonical_contact)
232}
233
234fn read_response<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
236 canonical(
237 reader,
238 "security_response",
239 crate::config::canonical_response,
240 )
241}
242
243fn canonical<'de, D: serde::Deserializer<'de>>(
245 reader: D,
246 field: &str,
247 judge: impl Fn(&str) -> Result<String, String>,
248) -> Result<String, D::Error> {
249 let raw = String::deserialize(reader)?;
250 let canonical = judge(&raw)
251 .map_err(|reason| serde::de::Error::custom(format!("parameters.{field}: {reason}")))?;
252 if canonical == raw {
253 Ok(canonical)
254 } else {
255 Err(serde::de::Error::custom(format!(
256 "parameters.{field} is not canonical: the record carries {raw:?} where a landing writes {canonical:?}"
257 )))
258 }
259}
260
261#[derive(Debug, Serialize, Deserialize)]
263pub struct FileRecord {
264 pub destination: String,
266 pub kind: Kind,
268 pub sha256: Digest,
271 #[serde(default, skip_serializing_if = "Placement::is_whole")]
275 pub placement: Placement,
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
280#[serde(rename_all = "lowercase")]
281pub enum Placement {
282 #[default]
284 Whole,
285 Region,
288}
289
290impl Placement {
291 #[must_use]
293 pub const fn is_whole(&self) -> bool {
294 matches!(self, Self::Whole)
295 }
296
297 #[must_use]
299 pub const fn as_str(self) -> &'static str {
300 match self {
301 Self::Whole => "whole",
302 Self::Region => "region",
303 }
304 }
305}
306
307pub mod legacy {
314 pub fn convert(mut value: serde_json::Value) -> serde_json::Value {
322 if let Some(record) = value.as_object_mut() {
323 record.remove("payload_sha256");
324 if let Some(parameters) = record
325 .get_mut("parameters")
326 .and_then(serde_json::Value::as_object_mut)
327 {
328 parameters.remove("scopes");
329 }
330 if let Some(files) = record
331 .get_mut("files")
332 .and_then(serde_json::Value::as_array_mut)
333 {
334 for file in files
335 .iter_mut()
336 .filter_map(serde_json::Value::as_object_mut)
337 {
338 file.remove("baseline_sha256");
339 }
340 }
341 }
342 value
343 }
344}
345
346impl Manifest {
347 #[must_use]
349 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
350 self.files
351 .iter()
352 .find(|file| file.destination == destination)
353 }
354}
355
356pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
365 let path = target.join(MANIFEST_PATH);
366 let bytes = match std::fs::read(&path) {
367 Ok(bytes) => bytes,
368 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
369 Err(e) => {
370 return Err(RkError::refusal(
371 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
372 .expected("a readable landing record")
373 .target_state("unchanged"),
374 ));
375 }
376 };
377 let value: serde_json::Value = serde_json::from_slice(&bytes)
378 .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
379 let schema = value
384 .get("schema_version")
385 .and_then(serde_json::Value::as_u64);
386 if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
387 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
388 return Err(RkError::refusal(
389 Diagnostic::new(
390 Reason::UnsupportedSchema,
391 format!(
392 "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
393 ),
394 )
395 .expected("a landing record at a schema this binary knows")
396 .action("install the rk release that wrote this record, or a newer one")
397 .target_state("unchanged"),
398 ));
399 }
400 let declared = schema.unwrap_or(SCHEMA_VERSION);
401 let value = if declared < SCHEMA_VERSION {
402 legacy::convert(value)
403 } else {
404 value
405 };
406 let mut manifest: Manifest = serde_json::from_value(value)
407 .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version {declared}: {e}"))?;
408 for file in &mut manifest.files {
411 if declared < SCHEMA_VERSION && crate::landing::block_markers(&file.destination).is_some() {
412 file.placement = Placement::Region;
413 }
414 }
415 Ok(Some(manifest))
416}
417
418pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
424 let path = target.join(MANIFEST_PATH);
425 atomic::write(path.as_std_path(), &render(manifest)?)?;
426 Ok(())
427}
428
429pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
435 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
436 Ok(format!("{text}\n").into_bytes())
437}
438
439#[must_use]
441pub fn now() -> String {
442 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
447#[serde(rename_all = "kebab-case")]
448pub enum Alignment {
449 Aligned,
451 BinaryNewer,
453 TargetNewer,
456}
457
458impl Alignment {
459 #[must_use]
461 pub const fn as_str(self) -> &'static str {
462 match self {
463 Self::Aligned => "aligned",
464 Self::BinaryNewer => "binary-newer",
465 Self::TargetNewer => "target-newer",
466 }
467 }
468}
469
470#[must_use]
472pub fn alignment(recorded: &str, binary: &str) -> Alignment {
473 let recorded = recorded
475 .split_once('+')
476 .map_or(recorded, |(version, _)| version);
477 let binary = binary
478 .split_once('+')
479 .map_or(binary, |(version, _)| version);
480 let recorded_core = numeric_core(recorded);
481 let binary_core = numeric_core(binary);
482 match binary_core.cmp(&recorded_core) {
483 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
484 std::cmp::Ordering::Less => Alignment::TargetNewer,
485 std::cmp::Ordering::Equal => {
486 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
491 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
492 match (recorded_pre, binary_pre) {
493 (Some(_), None) => Alignment::BinaryNewer,
494 (None, Some(_)) => Alignment::TargetNewer,
495 (None, None) => Alignment::Aligned,
496 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
497 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
498 std::cmp::Ordering::Less => Alignment::TargetNewer,
499 std::cmp::Ordering::Equal => Alignment::Aligned,
500 },
501 }
502 }
503 }
504}
505
506#[must_use]
509pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
510 alignment(pinned, candidate) == Alignment::BinaryNewer
511}
512
513fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
520 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
521 let mut left = a.split('.');
522 let mut right = b.split('.');
523 loop {
524 match (left.next(), right.next()) {
525 (None, None) => return std::cmp::Ordering::Equal,
526 (None, Some(_)) => return std::cmp::Ordering::Less,
527 (Some(_), None) => return std::cmp::Ordering::Greater,
528 (Some(x), Some(y)) => {
529 let ordering = match (numeric(x), numeric(y)) {
530 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
531 (true, false) => std::cmp::Ordering::Less,
532 (false, true) => std::cmp::Ordering::Greater,
533 (false, false) => x.cmp(y),
534 };
535 if ordering != std::cmp::Ordering::Equal {
536 return ordering;
537 }
538 }
539 }
540 }
541}
542
543fn numeric_core(version: &str) -> Vec<u64> {
545 let core = version.split_once('-').map_or(version, |(core, _)| core);
546 core.split('.')
547 .map(|part| part.parse::<u64>().unwrap_or(0))
548 .collect()
549}
550
551#[cfg(test)]
552mod tests {
553 use super::{
554 Alignment, FileRecord, Manifest, Parameters, Placement, Style, Workflow, alignment,
555 };
556 use crate::digest::Digest;
557 use crate::landing::Kind;
558
559 #[test]
563 fn the_manifest_schema_snapshot_holds() {
564 let manifest = Manifest {
565 schema_version: 7,
566 rk_version: "0.1.0".into(),
567 origin: "init".into(),
568 tech: "rust".into(),
569 forge: "github".into(),
570 landed_at: "2026-08-29T00:00:00Z".into(),
571 parameters: Parameters {
572 repo: "acme/widget".into(),
573 workflow: Workflow::Worktree,
574 style: Some(Style::Trunk),
575 nix: true,
576 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
577 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
578 security_contact: String::new(),
579 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
580 },
581 files: vec![
582 FileRecord {
583 destination: "release-plz.toml".into(),
584 kind: Kind::Seeded,
585 sha256: Digest::of(b""),
586 placement: Placement::Whole,
587 },
588 FileRecord {
589 destination: "AGENTS.md".into(),
590 kind: Kind::Rendered,
591 sha256: Digest::of(b""),
592 placement: Placement::Region,
593 },
594 ],
595 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
596 };
597 let empty = Digest::of(b"").to_string();
598 let text = serde_json::to_string(&manifest).expect("a manifest serializes");
599 assert_eq!(
600 text,
601 format!(
602 r#"{{"schema_version":7,"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,"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"}}}}"#
603 ),
604 "a whole file omits its placement, and no retired digest field survives"
605 );
606 assert!(!text.contains("payload_sha256") && !text.contains("baseline_sha256"));
607 }
608
609 #[test]
615 fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
616 let dir = tempfile::tempdir().expect("a scratch target exists");
617 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
618 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
619 let record = |schema: u64| {
620 format!(
621 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":{{}}}}"#
622 )
623 };
624 std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
625 let manifest = super::load(target)
626 .expect("a schema-1 record loads")
627 .expect("the record exists");
628 assert_eq!(manifest.parameters.workflow, Workflow::Branches);
629 assert_eq!(
630 manifest.parameters.style, None,
631 "a pre-style record carries no style; the upgrade demands one"
632 );
633 assert!(
634 !manifest.parameters.nix,
635 "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
636 );
637 assert_eq!(
638 manifest.parameters.security_contact, "",
639 "a pre-policy record names no contact, which is what its policy landed"
640 );
641 assert_eq!(
642 manifest.parameters.security_response,
643 crate::config::RESPONSE_DEFAULT,
644 "a pre-policy record promises no window, which is what its policy landed"
645 );
646
647 for schema in 2..=6 {
648 std::fs::write(
649 target.join(super::MANIFEST_PATH),
650 format!(
651 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":{{}}}}"#
652 ),
653 )
654 .expect("the record writes");
655 let manifest = super::load(target)
656 .expect("an earlier record loads")
657 .expect("the record exists");
658 assert_eq!(manifest.schema_version, schema);
659 assert_eq!(
660 manifest.files[0].placement,
661 Placement::Region,
662 "a block destination reads as a region"
663 );
664 let rewritten = super::render(&manifest).expect("renders");
665 let text = String::from_utf8(rewritten).expect("text");
666 assert!(!text.contains("baseline_sha256"), "{text}");
667 }
668
669 std::fs::write(target.join(super::MANIFEST_PATH), record(999)).expect("the record writes");
670 let refused = super::load(target).expect_err("a schema-999 record refuses");
671 assert_eq!(
672 refused.reason(),
673 crate::diagnostic::Reason::UnsupportedSchema
674 );
675 let message = refused.to_string();
676 assert!(message.contains("999"), "{message}");
677 assert!(message.contains(super::MANIFEST_PATH), "{message}");
678 assert!(
679 !message.to_lowercase().contains("payload"),
680 "the record schema stands alone: {message}"
681 );
682 }
683
684 #[test]
689 fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
690 let dir = tempfile::tempdir().expect("a scratch target exists");
691 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
692 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
693 for (field, value) in [
694 ("security_contact", "team@acme.example\\nsecond line"),
697 ("security_contact", " team@acme.example "),
698 ("security_response", "90d"),
699 ("security_response", "0 days"),
700 ("security_response", "07 days"),
701 ("security_response", "1 days"),
702 ("security_response", ""),
703 ] {
704 let record = format!(
705 r#"{{"schema_version":7,"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":{{}}}}"#
706 );
707 std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
708 let refused = super::load(target).expect_err("an uncanonical record refuses");
709 assert!(refused.to_string().contains(field), "{field}: {refused}");
710 }
711 }
712
713 #[test]
714 fn alignment_orders_versions_numerically() {
715 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
716 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
717 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
718 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
719 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
720 }
721
722 #[test]
727 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
728 assert_eq!(
729 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
730 Alignment::TargetNewer
731 );
732 assert_eq!(
733 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
734 Alignment::BinaryNewer
735 );
736 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
737 assert_eq!(
738 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
739 Alignment::BinaryNewer
740 );
741 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
742 assert_eq!(
743 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
744 Alignment::TargetNewer,
745 "identifiers past the u64 range still compare numerically"
746 );
747 assert_eq!(
748 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
749 Alignment::BinaryNewer
750 );
751 }
752
753 #[test]
756 fn alignment_ignores_build_metadata() {
757 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
758 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
759 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
760 assert_eq!(
761 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
762 Alignment::Aligned
763 );
764 assert_eq!(
765 alignment("1.2.10-rc.1+build", "1.2.10"),
766 Alignment::BinaryNewer
767 );
768 }
769}