1use std::sync::Arc;
2use std::time::Duration;
3
4use futures::StreamExt;
5use object_store::{ObjectStore, ObjectStoreExt};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9use super::ClusterKv;
10use crate::checkpoint::{CheckpointParticipant, MAX_CHECKPOINT_PARTICIPANTS};
11use crate::cluster::discovery::NodeId;
12
13const NAMESPACE_PROOF_KEY: &str = "control:shared-namespace-proof-v1";
14const NAMESPACE_PROOF_VERSION: u8 = 1;
15const NAMESPACE_PROOF_MAX_RECORD_BYTES: usize = 512;
16const NAMESPACE_PROOF_MAX_SENTINEL_BYTES: u64 = 512;
17const NAMESPACE_PROOF_RETRY_INTERVAL: Duration = Duration::from_millis(100);
18const NAMESPACE_PROOF_READ_CONCURRENCY: usize = 16;
19
20pub const MAX_SHARED_NAMESPACE_PROOF_TIMEOUT: Duration = Duration::from_secs(60);
22
23#[derive(Clone)]
25pub struct VerifiedClusterNamespaces {
26 checkpoint: Arc<dyn ObjectStore>,
27 state: Arc<dyn ObjectStore>,
28 local: CheckpointParticipant,
29}
30
31impl std::fmt::Debug for VerifiedClusterNamespaces {
32 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 formatter
34 .debug_struct("VerifiedClusterNamespaces")
35 .finish_non_exhaustive()
36 }
37}
38
39impl VerifiedClusterNamespaces {
40 #[must_use]
42 pub fn checkpoint_store(&self) -> Arc<dyn ObjectStore> {
43 Arc::clone(&self.checkpoint)
44 }
45
46 #[must_use]
48 pub fn state_store(&self) -> Arc<dyn ObjectStore> {
49 Arc::clone(&self.state)
50 }
51
52 #[must_use]
54 pub const fn local_participant(&self) -> CheckpointParticipant {
55 self.local
56 }
57}
58
59#[derive(Debug, thiserror::Error)]
61#[error("{message}")]
62pub struct NamespaceProofError {
63 message: String,
64}
65
66impl NamespaceProofError {
67 fn configuration(message: &'static str) -> Self {
68 Self {
69 message: message.to_string(),
70 }
71 }
72
73 fn verification(message: &str) -> Self {
74 Self {
75 message: format!("shared checkpoint/state namespace proof failed: {message}"),
76 }
77 }
78
79 fn timeout(timeout: Duration) -> Self {
80 Self {
81 message: format!("shared checkpoint/state namespace proof exceeded {timeout:?}"),
82 }
83 }
84}
85
86#[derive(Deserialize, Serialize)]
87#[serde(deny_unknown_fields)]
88struct NamespaceProofRecord {
89 version: u8,
90 node_id: u64,
91 boot_incarnation: uuid::Uuid,
92 nonce: uuid::Uuid,
93 roster_sha256: String,
94}
95
96impl NamespaceProofRecord {
97 fn validate_identity(&self, participant: CheckpointParticipant) -> Result<(), String> {
98 if self.version != NAMESPACE_PROOF_VERSION
99 || self.node_id != participant.node_id
100 || self.boot_incarnation != participant.boot_incarnation
101 || self.nonce.is_nil()
102 || self.roster_sha256.len() != 64
103 || !self
104 .roster_sha256
105 .bytes()
106 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
107 {
108 return Err(format!(
109 "node {} published a stale or mismatched shared-namespace proof",
110 participant.node_id
111 ));
112 }
113 Ok(())
114 }
115}
116
117#[derive(Clone, Copy)]
118enum NamespaceProofStore {
119 Checkpoint,
120 State,
121}
122
123impl NamespaceProofStore {
124 const fn name(self) -> &'static str {
125 match self {
126 Self::Checkpoint => "checkpoint",
127 Self::State => "state",
128 }
129 }
130}
131
132fn namespace_proof_roster_sha256(participants: &[CheckpointParticipant]) -> String {
133 const HEX: &[u8; 16] = b"0123456789abcdef";
134
135 let mut hash = Sha256::new();
136 hash.update(b"LAMINAR_SHARED_NAMESPACE_ROSTER_V1\0");
137 hash.update(
138 u64::try_from(participants.len())
139 .unwrap_or(u64::MAX)
140 .to_be_bytes(),
141 );
142 for participant in participants {
143 hash.update(participant.node_id.to_be_bytes());
144 hash.update(participant.boot_incarnation.as_bytes());
145 }
146 let digest = hash.finalize();
147 let mut encoded = String::with_capacity(digest.len() * 2);
148 for byte in digest {
149 encoded.push(HEX[(byte >> 4) as usize] as char);
150 encoded.push(HEX[(byte & 0x0f) as usize] as char);
151 }
152 encoded
153}
154
155fn namespace_proof_path(store: NamespaceProofStore, node_id: u64) -> object_store::path::Path {
156 object_store::path::Path::from(format!(
157 "cluster-namespace-proof/v1/{}/node={node_id}/sentinel",
158 store.name()
159 ))
160}
161
162fn namespace_proof_sentinel(
163 store: NamespaceProofStore,
164 record: &NamespaceProofRecord,
165) -> bytes::Bytes {
166 bytes::Bytes::from(format!(
167 "LAMINAR_SHARED_NAMESPACE_V1\n{}\n{}\n{}\n{}\n{}\n",
168 store.name(),
169 record.node_id,
170 record.boot_incarnation,
171 record.nonce,
172 record.roster_sha256
173 ))
174}
175
176async fn write_namespace_proof_sentinel(
177 object_store: &Arc<dyn ObjectStore>,
178 store: NamespaceProofStore,
179 record: &NamespaceProofRecord,
180) -> Result<(), String> {
181 let payload = namespace_proof_sentinel(store, record);
182 if u64::try_from(payload.len()).unwrap_or(u64::MAX) > NAMESPACE_PROOF_MAX_SENTINEL_BYTES {
183 return Err("shared-namespace sentinel exceeds its fixed size bound".into());
184 }
185 object_store
186 .put(
187 &namespace_proof_path(store, record.node_id),
188 object_store::PutPayload::from(payload),
189 )
190 .await
191 .map(|_| ())
192 .map_err(|error| format!("write {} namespace sentinel: {error}", store.name()))
193}
194
195async fn read_namespace_proof_sentinel(
196 object_store: &Arc<dyn ObjectStore>,
197 store: NamespaceProofStore,
198 record: &NamespaceProofRecord,
199) -> Result<(), String> {
200 let result = object_store
201 .get(&namespace_proof_path(store, record.node_id))
202 .await
203 .map_err(|error| {
204 format!(
205 "read node {} {} namespace sentinel: {error}",
206 record.node_id,
207 store.name()
208 )
209 })?;
210 if result.meta.size == 0 || result.meta.size > NAMESPACE_PROOF_MAX_SENTINEL_BYTES {
211 return Err(format!(
212 "node {} {} namespace sentinel is {} bytes; maximum is {}",
213 record.node_id,
214 store.name(),
215 result.meta.size,
216 NAMESPACE_PROOF_MAX_SENTINEL_BYTES
217 ));
218 }
219 let bytes = result.bytes().await.map_err(|error| {
220 format!(
221 "read node {} {} namespace sentinel body: {error}",
222 record.node_id,
223 store.name()
224 )
225 })?;
226 if bytes != namespace_proof_sentinel(store, record) {
227 return Err(format!(
228 "node {} {} namespace sentinel does not match its boot proof",
229 record.node_id,
230 store.name()
231 ));
232 }
233 Ok(())
234}
235
236async fn verify_namespace_proof_visibility(
237 control: &Arc<dyn ClusterKv>,
238 checkpoint_store: &Arc<dyn ObjectStore>,
239 state_store: &Arc<dyn ObjectStore>,
240 participants: &[CheckpointParticipant],
241 local: CheckpointParticipant,
242 roster_sha256: &str,
243) -> Result<(), String> {
244 let checks = futures::stream::iter(participants.iter().copied())
245 .map(|participant| async move {
246 let encoded = control
247 .read_from_checked(NodeId(participant.node_id), NAMESPACE_PROOF_KEY)
248 .await?
249 .ok_or_else(|| {
250 format!(
251 "node {} has not published its shared-namespace proof",
252 participant.node_id
253 )
254 })?;
255 if encoded.len() > NAMESPACE_PROOF_MAX_RECORD_BYTES {
256 return Err(format!(
257 "node {} shared-namespace proof exceeds {} bytes",
258 participant.node_id, NAMESPACE_PROOF_MAX_RECORD_BYTES
259 ));
260 }
261 let record: NamespaceProofRecord = serde_json::from_str(&encoded).map_err(|error| {
262 format!(
263 "decode node {} shared-namespace proof: {error}",
264 participant.node_id
265 )
266 })?;
267 record.validate_identity(participant)?;
268 if participant == local && record.roster_sha256 != roster_sha256 {
269 return Err("local shared-namespace proof has the wrong startup roster".into());
270 }
271 tokio::try_join!(
272 read_namespace_proof_sentinel(
273 checkpoint_store,
274 NamespaceProofStore::Checkpoint,
275 &record,
276 ),
277 read_namespace_proof_sentinel(state_store, NamespaceProofStore::State, &record),
278 )?;
279 Ok::<_, String>(())
280 })
281 .buffer_unordered(NAMESPACE_PROOF_READ_CONCURRENCY);
282 let results: Vec<Result<(), String>> = checks.collect().await;
283 for result in results {
284 result?;
285 }
286 Ok(())
287}
288
289async fn wait_for_namespace_proof_visibility(
290 control: &Arc<dyn ClusterKv>,
291 checkpoint_store: &Arc<dyn ObjectStore>,
292 state_store: &Arc<dyn ObjectStore>,
293 participants: &[CheckpointParticipant],
294 local: CheckpointParticipant,
295 roster_sha256: &str,
296 deadline: tokio::time::Instant,
297) -> Result<(), String> {
298 let mut last_failure = "no verification attempt completed".to_string();
299 loop {
300 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
301 if remaining.is_zero() {
302 return Err(format!(
303 "shared-namespace peer visibility timed out: {last_failure}"
304 ));
305 }
306 match tokio::time::timeout(
307 remaining,
308 verify_namespace_proof_visibility(
309 control,
310 checkpoint_store,
311 state_store,
312 participants,
313 local,
314 roster_sha256,
315 ),
316 )
317 .await
318 {
319 Ok(Ok(())) => return Ok(()),
320 Ok(Err(error)) => last_failure = error,
321 Err(_) => {
322 return Err(format!(
323 "shared-namespace peer visibility timed out: {last_failure}"
324 ));
325 }
326 }
327 tokio::time::sleep(
328 NAMESPACE_PROOF_RETRY_INTERVAL
329 .min(deadline.saturating_duration_since(tokio::time::Instant::now())),
330 )
331 .await;
332 }
333}
334
335pub async fn prove_shared_object_store_namespaces(
342 local: CheckpointParticipant,
343 participants: &[CheckpointParticipant],
344 control: Arc<dyn ClusterKv>,
345 checkpoint_store: Arc<dyn ObjectStore>,
346 state_store: Arc<dyn ObjectStore>,
347 timeout: Duration,
348) -> Result<VerifiedClusterNamespaces, NamespaceProofError> {
349 if participants.is_empty()
350 || participants.len() > MAX_CHECKPOINT_PARTICIPANTS
351 || participants
352 .windows(2)
353 .any(|pair| pair[0].node_id >= pair[1].node_id)
354 || participants
355 .iter()
356 .any(|participant| participant.node_id == 0 || participant.boot_incarnation.is_nil())
357 || participants
358 .iter()
359 .filter(|participant| **participant == local)
360 .count()
361 != 1
362 {
363 return Err(NamespaceProofError::configuration(
364 "shared-namespace proof requires one canonical exact startup roster",
365 ));
366 }
367 let timeout = timeout.min(MAX_SHARED_NAMESPACE_PROOF_TIMEOUT);
368 if timeout.is_zero() {
369 return Err(NamespaceProofError::configuration(
370 "shared-namespace proof timeout is zero",
371 ));
372 }
373 let roster_sha256 = namespace_proof_roster_sha256(participants);
374 let record = NamespaceProofRecord {
375 version: NAMESPACE_PROOF_VERSION,
376 node_id: local.node_id,
377 boot_incarnation: local.boot_incarnation,
378 nonce: uuid::Uuid::new_v4(),
379 roster_sha256: roster_sha256.clone(),
380 };
381 let deadline = tokio::time::Instant::now() + timeout;
382 let proof = async {
383 tokio::try_join!(
384 write_namespace_proof_sentinel(
385 &checkpoint_store,
386 NamespaceProofStore::Checkpoint,
387 &record,
388 ),
389 write_namespace_proof_sentinel(&state_store, NamespaceProofStore::State, &record),
390 )?;
391 let encoded = serde_json::to_string(&record).map_err(|error| error.to_string())?;
392 if encoded.len() > NAMESPACE_PROOF_MAX_RECORD_BYTES {
393 return Err("local shared-namespace proof exceeds its size bound".to_string());
394 }
395 control.write_checked(NAMESPACE_PROOF_KEY, encoded).await?;
396 wait_for_namespace_proof_visibility(
397 &control,
398 &checkpoint_store,
399 &state_store,
400 participants,
401 local,
402 &roster_sha256,
403 deadline,
404 )
405 .await
406 };
407 match tokio::time::timeout(timeout, proof).await {
408 Ok(Ok(())) => Ok(VerifiedClusterNamespaces {
409 checkpoint: checkpoint_store,
410 state: state_store,
411 local,
412 }),
413 Ok(Err(error)) => Err(NamespaceProofError::verification(&error)),
414 Err(_) => Err(NamespaceProofError::timeout(timeout)),
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use std::collections::HashMap;
421
422 use async_trait::async_trait;
423 use parking_lot::Mutex;
424
425 use super::*;
426
427 #[derive(Clone)]
428 struct NamespaceProofTestKv {
429 local_id: NodeId,
430 values: Arc<Mutex<HashMap<(NodeId, String), String>>>,
431 }
432
433 #[async_trait]
434 impl ClusterKv for NamespaceProofTestKv {
435 async fn write(&self, key: &str, value: String) {
436 self.values
437 .lock()
438 .insert((self.local_id, key.to_string()), value);
439 }
440
441 async fn read_from(&self, who: NodeId, key: &str) -> Option<String> {
442 self.values.lock().get(&(who, key.to_string())).cloned()
443 }
444
445 async fn scan(&self, key: &str) -> Vec<(NodeId, String)> {
446 self.values
447 .lock()
448 .iter()
449 .filter(|((_, stored_key), _)| stored_key == key)
450 .map(|((node, _), value)| (*node, value.clone()))
451 .collect()
452 }
453 }
454
455 fn participants() -> [CheckpointParticipant; 2] {
456 [
457 CheckpointParticipant {
458 node_id: 1,
459 boot_incarnation: uuid::Uuid::from_u128(11),
460 },
461 CheckpointParticipant {
462 node_id: 2,
463 boot_incarnation: uuid::Uuid::from_u128(22),
464 },
465 ]
466 }
467
468 fn controls() -> [Arc<dyn ClusterKv>; 2] {
469 let values = Arc::new(Mutex::new(HashMap::new()));
470 [
471 Arc::new(NamespaceProofTestKv {
472 local_id: NodeId(1),
473 values: Arc::clone(&values),
474 }),
475 Arc::new(NamespaceProofTestKv {
476 local_id: NodeId(2),
477 values,
478 }),
479 ]
480 }
481
482 async fn run_two_node_proof(
483 participants: &[CheckpointParticipant; 2],
484 controls: &[Arc<dyn ClusterKv>; 2],
485 checkpoint_stores: [Arc<dyn ObjectStore>; 2],
486 state_stores: [Arc<dyn ObjectStore>; 2],
487 timeout: Duration,
488 ) -> [Result<VerifiedClusterNamespaces, NamespaceProofError>; 2] {
489 let first = prove_shared_object_store_namespaces(
490 participants[0],
491 participants,
492 Arc::clone(&controls[0]),
493 Arc::clone(&checkpoint_stores[0]),
494 Arc::clone(&state_stores[0]),
495 timeout,
496 );
497 let second = prove_shared_object_store_namespaces(
498 participants[1],
499 participants,
500 Arc::clone(&controls[1]),
501 Arc::clone(&checkpoint_stores[1]),
502 Arc::clone(&state_stores[1]),
503 timeout,
504 );
505 let (first, second) = tokio::join!(first, second);
506 [first, second]
507 }
508
509 async fn marker_count(store: &Arc<dyn ObjectStore>, role: NamespaceProofStore) -> usize {
510 let prefix =
511 object_store::path::Path::from(format!("cluster-namespace-proof/v1/{}/", role.name()));
512 let mut entries = store.list(Some(&prefix));
513 let mut count = 0;
514 while let Some(entry) = entries.next().await {
515 entry.unwrap();
516 count += 1;
517 }
518 count
519 }
520
521 #[tokio::test]
522 async fn proof_retains_bounded_markers_and_exact_handles() {
523 let checkpoint: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
524 let state: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
525 let participants = participants();
526 let controls = controls();
527 let results = run_two_node_proof(
528 &participants,
529 &controls,
530 [Arc::clone(&checkpoint), Arc::clone(&checkpoint)],
531 [Arc::clone(&state), Arc::clone(&state)],
532 Duration::from_secs(2),
533 )
534 .await;
535 assert!(results.iter().all(Result::is_ok));
536
537 let admitted = results[0].as_ref().unwrap();
538 assert!(Arc::ptr_eq(&admitted.checkpoint_store(), &checkpoint));
539 assert!(Arc::ptr_eq(&admitted.state_store(), &state));
540 assert_eq!(admitted.local_participant(), participants[0]);
541 for node_id in [1, 2] {
542 for (store, role) in [
543 (&checkpoint, NamespaceProofStore::Checkpoint),
544 (&state, NamespaceProofStore::State),
545 ] {
546 let marker = store
547 .get(&namespace_proof_path(role, node_id))
548 .await
549 .expect("boot marker must remain available to rolling joiners");
550 assert!(marker.meta.size <= NAMESPACE_PROOF_MAX_SENTINEL_BYTES);
551 }
552 }
553 }
554
555 #[tokio::test]
556 async fn rolling_restart_uses_active_peers_retained_markers() {
557 let checkpoint: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
558 let state: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
559 let initial = participants();
560 let controls = controls();
561 let results = run_two_node_proof(
562 &initial,
563 &controls,
564 [Arc::clone(&checkpoint), Arc::clone(&checkpoint)],
565 [Arc::clone(&state), Arc::clone(&state)],
566 Duration::from_secs(2),
567 )
568 .await;
569 assert!(results.iter().all(Result::is_ok));
570 let active_peer_record = controls[1]
571 .read_from(NodeId(2), NAMESPACE_PROOF_KEY)
572 .await
573 .unwrap();
574
575 let restarted = [
576 CheckpointParticipant {
577 node_id: 1,
578 boot_incarnation: uuid::Uuid::from_u128(111),
579 },
580 initial[1],
581 ];
582 prove_shared_object_store_namespaces(
583 restarted[0],
584 &restarted,
585 Arc::clone(&controls[0]),
586 Arc::clone(&checkpoint),
587 Arc::clone(&state),
588 Duration::from_secs(1),
589 )
590 .await
591 .unwrap();
592 assert_eq!(
593 controls[1].read_from(NodeId(2), NAMESPACE_PROOF_KEY).await,
594 Some(active_peer_record),
595 "the active peer must not rerun startup for a rolling joiner"
596 );
597 assert_eq!(
598 marker_count(&checkpoint, NamespaceProofStore::Checkpoint).await,
599 2
600 );
601 assert_eq!(marker_count(&state, NamespaceProofStore::State).await, 2);
602 }
603
604 #[tokio::test]
605 async fn proof_rejects_split_state_namespaces() {
606 let checkpoint: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
607 let state_a: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
608 let state_b: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
609 let participants = participants();
610 let controls = controls();
611 let results = run_two_node_proof(
612 &participants,
613 &controls,
614 [Arc::clone(&checkpoint), checkpoint],
615 [state_a, state_b],
616 Duration::from_millis(250),
617 )
618 .await;
619 assert!(results.iter().all(Result::is_err));
620 }
621
622 #[tokio::test]
623 async fn proof_rejects_split_checkpoint_namespaces() {
624 let checkpoint_a: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
625 let checkpoint_b: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
626 let state: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
627 let participants = participants();
628 let controls = controls();
629 let results = run_two_node_proof(
630 &participants,
631 &controls,
632 [checkpoint_a, checkpoint_b],
633 [Arc::clone(&state), state],
634 Duration::from_millis(250),
635 )
636 .await;
637 assert!(results.iter().all(Result::is_err));
638 }
639}