1#![allow(clippy::disallowed_types)] use std::collections::{BTreeMap, BTreeSet};
6
7use serde::Serialize;
8use sha2::{Digest, Sha256};
9
10use super::{
11 channel_progress_frontiers_by_source, classify_channel_progress,
12 merge_node_subscription_manifests, ChannelProgress, CheckpointAssignmentFence,
13 CheckpointManifest, ConnectorCheckpoint, PipelineIdentity, SINGLETON_WATERMARK_CHANNEL,
14};
15use crate::state::{KeyGroupCount, LOCAL_NODE_ID};
16
17pub const COMMITTED_CHECKPOINT_INDEX_VERSION: u32 = 4;
19const LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION: u32 = 3;
20
21pub const MAX_COMMITTED_CHECKPOINT_INDEX_BYTES: usize = 8 * 1024 * 1024;
23
24#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum CheckpointScope {
28 Local,
30 Cluster,
32}
33
34#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case", tag = "state", content = "watermark_ms")]
37pub enum CheckpointWatermark {
38 #[default]
40 Uninitialized,
41 Idle,
43 Active(i64),
45}
46
47impl CheckpointWatermark {
48 #[must_use]
50 pub const fn cluster_min(self, other: Self) -> Self {
51 match (self, other) {
52 (Self::Uninitialized, _) | (_, Self::Uninitialized) => Self::Uninitialized,
53 (Self::Idle, Self::Idle) => Self::Idle,
54 (Self::Idle, Self::Active(value)) | (Self::Active(value), Self::Idle) => {
55 Self::Active(value)
56 }
57 (Self::Active(left), Self::Active(right)) => {
58 Self::Active(if left < right { left } else { right })
59 }
60 }
61 }
62
63 #[must_use]
65 pub const fn active_value(self) -> Option<i64> {
66 match self {
67 Self::Active(value) => Some(value),
68 Self::Uninitialized | Self::Idle => None,
69 }
70 }
71
72 pub fn validate(self) -> Result<(), String> {
77 if self == Self::Active(i64::MIN) {
78 return Err("active checkpoint watermark cannot use the uninitialized sentinel".into());
79 }
80 Ok(())
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct CommittedParticipantRef {
88 pub participant_id: u64,
90 pub manifest_len: u64,
92 pub manifest_sha256: String,
94 pub node_data_len: u64,
96 pub node_data_sha256: String,
98}
99
100impl CommittedParticipantRef {
101 pub fn from_manifest(
106 manifest: &CheckpointManifest,
107 persisted_manifest: &[u8],
108 ) -> Result<Self, String> {
109 let decoded: CheckpointManifest = serde_json::from_slice(persisted_manifest)
110 .map_err(|error| format!("participant manifest is not valid JSON: {error}"))?;
111 if decoded != *manifest {
112 return Err("persisted participant manifest differs from the supplied manifest".into());
113 }
114 let reference = Self {
115 participant_id: manifest.participant_id,
116 manifest_len: u64::try_from(persisted_manifest.len())
117 .map_err(|_| "participant manifest length overflowed".to_owned())?,
118 manifest_sha256: sha256_hex(persisted_manifest),
119 node_data_len: manifest.node_data.object_length,
120 node_data_sha256: manifest.node_data.sha256.clone(),
121 };
122 reference.validate()?;
123 Ok(reference)
124 }
125
126 pub fn validate(&self) -> Result<(), String> {
131 if self.participant_id == 0 {
132 return Err("committed participant ID cannot be zero".into());
133 }
134 if self.manifest_len == 0 {
135 return Err("committed participant manifest cannot be empty".into());
136 }
137 validate_digest("participant manifest", &self.manifest_sha256)?;
138 validate_digest("participant node data", &self.node_data_sha256)?;
139 Ok(())
140 }
141
142 pub fn verify_manifest(
147 &self,
148 manifest: &CheckpointManifest,
149 persisted_manifest: &[u8],
150 ) -> Result<(), String> {
151 self.validate()?;
152 if u64::try_from(persisted_manifest.len()).unwrap_or(u64::MAX) != self.manifest_len
153 || sha256_hex(persisted_manifest) != self.manifest_sha256
154 {
155 return Err(format!(
156 "participant {} manifest length or digest differs from the committed index",
157 self.participant_id
158 ));
159 }
160 let decoded: CheckpointManifest = serde_json::from_slice(persisted_manifest)
161 .map_err(|error| format!("participant manifest is not valid JSON: {error}"))?;
162 if decoded != *manifest {
163 return Err(format!(
164 "participant {} decoded manifest differs from its persisted bytes",
165 self.participant_id
166 ));
167 }
168 if manifest.participant_id != self.participant_id
169 || manifest.node_data.chunk.participant_id != self.participant_id
170 || manifest.node_data.object_length != self.node_data_len
171 || manifest.node_data.sha256 != self.node_data_sha256
172 {
173 return Err(format!(
174 "participant {} manifest does not match its committed object reference",
175 self.participant_id
176 ));
177 }
178 Ok(())
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct CommittedCheckpointRef {
186 pub epoch: u64,
188 pub checkpoint_id: u64,
190 pub sha256: String,
192 pub len: u64,
194}
195
196impl CommittedCheckpointRef {
197 pub fn validate(&self) -> Result<(), String> {
202 validate_attempt(
203 self.epoch,
204 self.checkpoint_id,
205 "committed checkpoint reference",
206 )?;
207 validate_digest("committed checkpoint", &self.sha256)?;
208 if self.len == 0
209 || self.len > u64::try_from(MAX_COMMITTED_CHECKPOINT_INDEX_BYTES).unwrap_or(u64::MAX)
210 {
211 return Err(format!(
212 "committed checkpoint index length {} is outside 1..={MAX_COMMITTED_CHECKPOINT_INDEX_BYTES}",
213 self.len
214 ));
215 }
216 Ok(())
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct CommittedCheckpointIndex {
224 pub version: u32,
226 pub deployment_id: String,
228 pub pipeline_identity: PipelineIdentity,
230 pub epoch: u64,
232 pub checkpoint_id: u64,
234 pub scope: CheckpointScope,
236 pub vnode_count: u16,
238 pub assignment_fence: Option<CheckpointAssignmentFence>,
240 pub reassignment_portable: bool,
242 pub predecessor: Option<CommittedCheckpointRef>,
244 pub participants: Vec<CommittedParticipantRef>,
246 pub source_names: Vec<String>,
248 pub source_offsets: BTreeMap<String, ConnectorCheckpoint>,
250 pub channel_progress: Vec<ChannelProgress>,
252 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
255 pub source_watermarks: BTreeMap<String, i64>,
256 pub checkpoint_watermark: Option<i64>,
258}
259
260impl CommittedCheckpointIndex {
261 pub fn validate(&self) -> Result<(), String> {
266 if !matches!(
267 self.version,
268 LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION | COMMITTED_CHECKPOINT_INDEX_VERSION
269 ) {
270 return Err(format!(
271 "unsupported committed checkpoint index version {}; expected {} or {COMMITTED_CHECKPOINT_INDEX_VERSION}",
272 self.version, LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION
273 ));
274 }
275 validate_attempt(self.epoch, self.checkpoint_id, "committed checkpoint index")?;
276 validate_deployment(&self.deployment_id)?;
277 if !self.pipeline_identity.is_canonical() {
278 return Err("committed checkpoint pipeline identity is not canonical".into());
279 }
280 KeyGroupCount::try_from(u32::from(self.vnode_count))
281 .map_err(|_| "committed checkpoint vnode count is invalid".to_owned())?;
282
283 if self.participants.is_empty() {
284 return Err("committed checkpoint has no participants".into());
285 }
286 for participant in &self.participants {
287 participant.validate()?;
288 }
289 if !self
290 .participants
291 .windows(2)
292 .all(|pair| pair[0].participant_id < pair[1].participant_id)
293 {
294 return Err(
295 "committed participants must be sorted and unique by participant ID".into(),
296 );
297 }
298
299 match (self.scope, self.assignment_fence.as_ref()) {
300 (CheckpointScope::Local, None)
301 if self.participants.len() == 1
302 && self.participants[0].participant_id == LOCAL_NODE_ID.0
303 && !self.reassignment_portable => {}
304 (CheckpointScope::Local, None) if self.reassignment_portable => {
305 return Err(
306 "local committed checkpoint cannot claim vnode reassignment portability".into(),
307 );
308 }
309 (CheckpointScope::Local, None) => {
310 return Err("local committed checkpoint requires only LOCAL_NODE_ID".into());
311 }
312 (CheckpointScope::Local, Some(_)) => {
313 return Err("local committed checkpoint cannot carry an assignment fence".into());
314 }
315 (CheckpointScope::Cluster, Some(fence)) => {
316 if !self.reassignment_portable {
317 return Err(
318 "cluster committed checkpoint must be portable across vnode reassignment"
319 .into(),
320 );
321 }
322 if !fence.is_canonical() || fence.vnode_count != u32::from(self.vnode_count) {
323 return Err(
324 "cluster committed checkpoint has an invalid assignment fence".into(),
325 );
326 }
327 let actual: Vec<_> = self
328 .participants
329 .iter()
330 .map(|participant| participant.participant_id)
331 .collect();
332 if actual != fence.participant_ids() {
333 return Err(
334 "committed participants do not exactly cover the assignment fence".into(),
335 );
336 }
337 }
338 (CheckpointScope::Cluster, None) => {
339 return Err("cluster committed checkpoint requires an assignment fence".into());
340 }
341 }
342
343 if let Some(predecessor) = &self.predecessor {
344 predecessor.validate()?;
345 if predecessor.epoch >= self.epoch || predecessor.checkpoint_id >= self.checkpoint_id {
346 return Err("committed checkpoint predecessor must be strictly older".into());
347 }
348 }
349
350 validate_source_topology(&self.source_names, &self.source_offsets)?;
351 validate_sources(&self.source_offsets, self.assignment_fence.as_ref())?;
352 validate_channel_progress(
353 &self.source_names,
354 &self.channel_progress,
355 self.checkpoint_watermark,
356 )?;
357 if self.version == LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION {
358 if !self.source_watermarks.is_empty() {
359 return Err("legacy committed checkpoint cannot carry source watermarks".into());
360 }
361 } else {
362 validate_source_watermarks(
363 &self.source_names,
364 &self.channel_progress,
365 &self.source_watermarks,
366 )?;
367 if self.predecessor.is_none() {
368 let exact = channel_progress_frontiers_by_source(&self.channel_progress)?
369 .into_iter()
370 .filter_map(|(source, frontier)| {
371 frontier.map(|frontier| (source.to_owned(), frontier))
372 })
373 .collect::<BTreeMap<_, _>>();
374 if self.source_watermarks != exact {
375 return Err(
376 "initial committed source watermarks do not exactly match channel progress"
377 .into(),
378 );
379 }
380 }
381 }
382 validate_channel_rosters(&self.source_offsets, &self.channel_progress)?;
383 if self.channel_progress.iter().any(|channel| {
384 self.participants
385 .binary_search_by_key(&channel.participant_id, |participant| {
386 participant.participant_id
387 })
388 .is_err()
389 }) {
390 return Err("channel progress names a participant outside the committed cut".into());
391 }
392 Ok(())
393 }
394
395 pub fn validate_predecessor_index(
400 &self,
401 predecessor: &CommittedCheckpointIndex,
402 ) -> Result<(), String> {
403 self.validate()?;
404 predecessor.validate()?;
405 if self.version < predecessor.version {
406 return Err(
407 "committed checkpoint index version regresses across its predecessor".into(),
408 );
409 }
410 let expected = self
411 .predecessor
412 .as_ref()
413 .ok_or_else(|| "committed checkpoint does not declare a predecessor".to_owned())?;
414 let (_, actual) = predecessor.encode_and_reference()?;
415 if &actual != expected
416 || predecessor.deployment_id != self.deployment_id
417 || predecessor.pipeline_identity != self.pipeline_identity
418 || predecessor.scope != self.scope
419 || predecessor.vnode_count != self.vnode_count
420 || predecessor.source_names != self.source_names
421 {
422 return Err("committed checkpoint predecessor breaks recovery continuity".into());
423 }
424 if self.version == COMMITTED_CHECKPOINT_INDEX_VERSION {
425 let mut expected = predecessor
426 .effective_source_watermarks()?
427 .into_iter()
428 .filter(|(source, _)| self.source_names.binary_search(source).is_ok())
429 .collect::<BTreeMap<_, _>>();
430 for (source, frontier) in channel_progress_frontiers_by_source(&self.channel_progress)?
431 {
432 let Some(frontier) = frontier else {
433 continue;
434 };
435 if expected
436 .get(source)
437 .is_some_and(|predecessor| *predecessor > frontier)
438 {
439 return Err(format!(
440 "committed source watermark for '{source}' regresses across its predecessor"
441 ));
442 }
443 expected.insert(source.to_owned(), frontier);
444 }
445 if self.source_watermarks != expected {
446 return Err(
447 "committed source watermarks do not exactly continue their predecessor".into(),
448 );
449 }
450 }
451 Ok(())
452 }
453
454 pub fn effective_source_watermarks(&self) -> Result<BTreeMap<String, i64>, String> {
462 if self.version == LEGACY_COMMITTED_CHECKPOINT_INDEX_VERSION {
463 return channel_progress_frontiers_by_source(&self.channel_progress).map(|frontiers| {
464 frontiers
465 .into_iter()
466 .filter_map(|(source, frontier)| {
467 frontier.map(|frontier| (source.to_owned(), frontier))
468 })
469 .collect()
470 });
471 }
472 Ok(self.source_watermarks.clone())
473 }
474
475 pub fn validate_participant_manifests(
480 &self,
481 manifests: &[(&CheckpointManifest, &[u8])],
482 ) -> Result<(), String> {
483 self.validate()?;
484 if manifests.len() != self.participants.len() {
485 return Err("participant manifest count differs from the committed index".into());
486 }
487
488 let key_group_count = KeyGroupCount::try_from(u32::from(self.vnode_count))
489 .map_err(|_| "committed checkpoint vnode count is invalid".to_owned())?;
490 let mut owners = vec![None; usize::from(self.vnode_count)];
491 let mut sink_names = None;
492 for ((manifest, encoded), reference) in manifests.iter().zip(&self.participants) {
493 reference.verify_manifest(manifest, encoded)?;
494 let errors = manifest.validate(key_group_count);
495 if let Some(error) = errors.first() {
496 return Err(format!(
497 "participant {} manifest is invalid: {error}",
498 reference.participant_id
499 ));
500 }
501 if manifest.checkpoint_id != self.checkpoint_id
502 || manifest.epoch != self.epoch
503 || manifest.deployment_id != self.deployment_id
504 || manifest.pipeline_identity != self.pipeline_identity
505 || manifest.vnode_count != self.vnode_count
506 || manifest.assignment_fence != self.assignment_fence
507 || manifest.reassignment_portable != self.reassignment_portable
508 {
509 return Err(format!(
510 "participant {} manifest belongs to a different checkpoint cut",
511 reference.participant_id
512 ));
513 }
514
515 if manifest.source_names != self.source_names {
516 return Err(
517 "participant manifest source topology differs from the committed index".into(),
518 );
519 }
520 match sink_names {
521 Some(expected) if expected != manifest.sink_names.as_slice() => {
522 return Err(
523 "participant manifests disagree on the registered sink topology".into(),
524 );
525 }
526 None => sink_names = Some(manifest.sink_names.as_slice()),
527 Some(_) => {}
528 }
529 for vnode in &manifest.owned_vnodes {
530 let owner = owners
531 .get_mut(usize::from(*vnode))
532 .ok_or_else(|| format!("manifest owns out-of-range vnode {vnode}"))?;
533 if owner.replace(manifest.participant_id).is_some() {
534 return Err(format!(
535 "vnode {vnode} is owned by more than one participant"
536 ));
537 }
538 }
539 }
540 if owners.iter().any(Option::is_none) {
541 return Err("participant manifests do not exactly cover the vnode domain".into());
542 }
543 if let Some(fence) = &self.assignment_fence {
544 let owner_map = owners.into_iter().flatten().collect::<Vec<_>>();
545 if !fence.matches_owner_map(&owner_map) {
546 return Err(
547 "committed manifest vnode owners do not match the assignment fence".into(),
548 );
549 }
550 let subscription_manifests = manifests
551 .iter()
552 .filter_map(|(manifest, _)| {
553 manifest
554 .subscription_output
555 .as_ref()
556 .map(|output| (output, manifest.owned_vnodes.as_slice()))
557 })
558 .collect::<Vec<_>>();
559 if !subscription_manifests.is_empty() {
560 if subscription_manifests.len() != manifests.len() {
561 return Err(
562 "participant manifests do not agree on subscription output presence".into(),
563 );
564 }
565 if subscription_manifests.iter().any(|(manifest, _)| {
566 manifest.streams.iter().any(|stream| {
567 stream.distribution_certificate.pipeline_identity != self.pipeline_identity
568 })
569 }) {
570 return Err(
571 "subscription output pipeline identity differs from the committed index"
572 .into(),
573 );
574 }
575 merge_node_subscription_manifests(
576 self.epoch,
577 self.checkpoint_id,
578 fence,
579 &subscription_manifests,
580 )
581 .map_err(|error| format!("committed subscription output is invalid: {error}"))?;
582 }
583 }
584 Ok(())
585 }
586
587 pub fn encode_and_reference(&self) -> Result<(Vec<u8>, CommittedCheckpointRef), String> {
592 self.validate()?;
593 let encoded = canonical_json_bytes(self).map_err(|error| error.to_string())?;
594 if encoded.len() > MAX_COMMITTED_CHECKPOINT_INDEX_BYTES {
595 return Err(format!(
596 "committed checkpoint index is {} bytes; maximum is {MAX_COMMITTED_CHECKPOINT_INDEX_BYTES}",
597 encoded.len()
598 ));
599 }
600 let reference = CommittedCheckpointRef {
601 epoch: self.epoch,
602 checkpoint_id: self.checkpoint_id,
603 sha256: sha256_hex(&encoded),
604 len: u64::try_from(encoded.len())
605 .map_err(|_| "committed checkpoint index length overflowed".to_owned())?,
606 };
607 reference.validate()?;
608 Ok((encoded, reference))
609 }
610}
611
612fn validate_source_topology(
613 source_names: &[String],
614 sources: &BTreeMap<String, ConnectorCheckpoint>,
615) -> Result<(), String> {
616 for source in source_names {
617 validate_name("source", source)?;
618 }
619 if !source_names.windows(2).all(|pair| pair[0] < pair[1]) {
620 return Err("committed source names must be sorted and unique".into());
621 }
622 if let Some(source) = sources
623 .keys()
624 .find(|source| source_names.binary_search(source).is_err())
625 {
626 return Err(format!(
627 "source offset '{source}' is absent from the committed source topology"
628 ));
629 }
630 Ok(())
631}
632
633fn validate_sources(
634 sources: &BTreeMap<String, ConnectorCheckpoint>,
635 fence: Option<&CheckpointAssignmentFence>,
636) -> Result<(), String> {
637 for (source, checkpoint) in sources {
638 validate_name("source", source)?;
639 for key in checkpoint.offsets.keys() {
640 validate_name("source offset key", key)?;
641 }
642 for key in checkpoint.metadata.keys() {
643 validate_name("source metadata key", key)?;
644 }
645 if let Some(channels) = &checkpoint.input_channels {
646 if channels.iter().any(Vec::is_empty)
647 || !channels.windows(2).all(|pair| pair[0] < pair[1])
648 {
649 return Err(format!(
650 "source '{source}' input channels must contain non-empty, sorted unique identities"
651 ));
652 }
653 if channels
654 .iter()
655 .any(|channel| channel == SINGLETON_WATERMARK_CHANNEL)
656 {
657 return Err(format!(
658 "source '{source}' input channels contain the reserved logical watermark channel"
659 ));
660 }
661 }
662 if let (Some(version), Some(fence)) = (checkpoint.source_assignment_version, fence) {
663 if version.get() != fence.assignment_version {
664 return Err(format!(
665 "source '{source}' assignment version is {}; expected {}",
666 version, fence.assignment_version
667 ));
668 }
669 }
670 }
671 Ok(())
672}
673
674fn validate_channel_progress(
675 source_names: &[String],
676 channels: &[ChannelProgress],
677 checkpoint_watermark: Option<i64>,
678) -> Result<(), String> {
679 for channel in channels {
680 if channel.participant_id == 0 {
681 return Err("channel progress participant ID cannot be zero".into());
682 }
683 validate_name("channel source", &channel.source_name)?;
684 if source_names.binary_search(&channel.source_name).is_err() {
685 return Err(format!(
686 "channel progress source '{}' is absent from the committed source topology",
687 channel.source_name
688 ));
689 }
690 if channel.input_channel.is_empty() {
691 return Err("channel progress input channel cannot be empty".into());
692 }
693 }
694 if !channels.windows(2).all(|pair| {
695 (
696 &pair[0].participant_id,
697 &pair[0].source_name,
698 &pair[0].input_channel,
699 ) < (
700 &pair[1].participant_id,
701 &pair[1].source_name,
702 &pair[1].input_channel,
703 )
704 }) {
705 return Err(
706 "channel progress must be sorted and unique by participant, source, and input channel"
707 .into(),
708 );
709 }
710 let mut logical_sources = BTreeSet::new();
711 let mut physical_sources = BTreeSet::new();
712 let mut physical_channels = BTreeSet::new();
713 for channel in channels {
714 if channel.input_channel == SINGLETON_WATERMARK_CHANNEL {
715 logical_sources.insert((channel.participant_id, channel.source_name.as_str()));
716 continue;
717 }
718 physical_sources.insert((channel.participant_id, channel.source_name.as_str()));
719 if !physical_channels.insert((&channel.source_name, &channel.input_channel)) {
720 return Err(format!(
721 "source '{}' input channel is owned by multiple participants",
722 channel.source_name
723 ));
724 }
725 }
726 if let Some((participant_id, source)) = logical_sources.intersection(&physical_sources).next() {
727 return Err(format!(
728 "channel progress participant {participant_id} source '{source}' mixes logical and physical input channels"
729 ));
730 }
731 let classification = classify_channel_progress(channels)?;
732 if checkpoint_watermark != classification.active_value() {
733 return Err("checkpoint watermark does not match channel progress".into());
734 }
735 Ok(())
736}
737
738fn validate_channel_rosters(
739 sources: &BTreeMap<String, ConnectorCheckpoint>,
740 channels: &[ChannelProgress],
741) -> Result<(), String> {
742 for (source, checkpoint) in sources {
743 let Some(expected) = checkpoint.input_channels.as_ref() else {
744 continue;
745 };
746 let mut actual = channels
747 .iter()
748 .filter(|channel| {
749 channel.source_name == *source
750 && channel.input_channel != SINGLETON_WATERMARK_CHANNEL
751 })
752 .map(|channel| channel.input_channel.as_slice())
753 .collect::<Vec<_>>();
754 if actual.is_empty() {
755 continue;
756 }
757 actual.sort_unstable();
758 if !actual.into_iter().eq(expected.iter().map(Vec::as_slice)) {
759 return Err(format!(
760 "source '{source}' input channels do not match its channel progress roster"
761 ));
762 }
763 }
764 Ok(())
765}
766
767fn validate_source_watermarks(
768 source_names: &[String],
769 channels: &[ChannelProgress],
770 source_watermarks: &BTreeMap<String, i64>,
771) -> Result<(), String> {
772 for (source, watermark) in source_watermarks {
773 validate_name("source watermark", source)?;
774 if source_names.binary_search(source).is_err() {
775 return Err(format!(
776 "source watermark '{source}' is absent from the committed source topology"
777 ));
778 }
779 if *watermark == i64::MIN {
780 return Err(format!(
781 "source watermark '{source}' uses the reserved uninitialized value"
782 ));
783 }
784 }
785 for (source, current) in channel_progress_frontiers_by_source(channels)? {
786 let Some(current) = current else {
787 continue;
788 };
789 if source_watermarks.get(source) != Some(¤t) {
790 return Err(format!(
791 "source watermark '{source}' does not match its exact channel progress"
792 ));
793 }
794 }
795 Ok(())
796}
797
798fn validate_attempt(epoch: u64, checkpoint_id: u64, kind: &str) -> Result<(), String> {
799 if epoch == 0 || checkpoint_id != epoch {
800 return Err(format!(
801 "{kind} must use one nonzero canonical checkpoint ID"
802 ));
803 }
804 Ok(())
805}
806
807fn validate_deployment(value: &str) -> Result<(), String> {
808 let deployment = uuid::Uuid::parse_str(value)
809 .map_err(|error| format!("committed checkpoint deployment identity is invalid: {error}"))?;
810 if deployment.is_nil() || deployment.to_string() != value {
811 return Err(
812 "committed checkpoint deployment identity must be canonical and non-nil".into(),
813 );
814 }
815 Ok(())
816}
817
818fn validate_name(kind: &str, value: &str) -> Result<(), String> {
819 if value.is_empty() || value.trim() != value {
820 return Err(format!(
821 "committed checkpoint has a non-canonical {kind} name"
822 ));
823 }
824 Ok(())
825}
826
827fn validate_digest(kind: &str, value: &str) -> Result<(), String> {
828 if value.len() != 64
829 || !value
830 .bytes()
831 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
832 {
833 return Err(format!(
834 "{kind} SHA-256 must be 64 lowercase hexadecimal characters"
835 ));
836 }
837 Ok(())
838}
839
840fn sha256_hex(bytes: &[u8]) -> String {
841 format!("{:x}", Sha256::digest(bytes))
842}
843
844#[derive(Serialize)]
845#[serde(untagged)]
846enum CanonicalJsonValue {
847 Null,
848 Bool(bool),
849 Number(serde_json::Number),
850 String(String),
851 Array(Vec<Self>),
852 Object(BTreeMap<String, Self>),
853}
854
855impl From<serde_json::Value> for CanonicalJsonValue {
856 fn from(value: serde_json::Value) -> Self {
857 match value {
858 serde_json::Value::Null => Self::Null,
859 serde_json::Value::Bool(value) => Self::Bool(value),
860 serde_json::Value::Number(value) => Self::Number(value),
861 serde_json::Value::String(value) => Self::String(value),
862 serde_json::Value::Array(values) => {
863 Self::Array(values.into_iter().map(Self::from).collect())
864 }
865 serde_json::Value::Object(values) => Self::Object(
866 values
867 .into_iter()
868 .map(|(key, value)| (key, Self::from(value)))
869 .collect(),
870 ),
871 }
872 }
873}
874
875pub fn canonical_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
880 let value = serde_json::to_value(value)?;
881 serde_json::to_vec(&CanonicalJsonValue::from(value))
882}
883
884pub fn canonical_json_sha256<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
889 canonical_json_bytes(value).map(|bytes| sha256_hex(&bytes))
890}
891
892#[cfg(test)]
893mod tests;