1use std::fmt::Debug;
15use std::future::Future;
16use std::io;
17use std::path::PathBuf;
18use std::pin::Pin;
19use std::sync::Arc;
20
21use serde::Deserialize;
22use serde::Serialize;
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub struct SnapshotKey {
30 pub raft_group_id: u32,
31 pub snapshot_id: String,
32}
33
34impl SnapshotKey {
35 pub fn filename(&self) -> String {
37 format!("{}.snap", self.snapshot_id)
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(tag = "kind", rename_all = "snake_case")]
44pub enum SnapshotLocation {
45 Inline {
48 #[serde(with = "serde_bytes_vec")]
49 bytes: Vec<u8>,
50 },
51 Local { path: PathBuf, size_bytes: u64 },
53 S3 { key: String, size_bytes: u64 },
55}
56
57impl SnapshotLocation {
58 pub fn size_hint(&self) -> u64 {
59 match self {
60 Self::Inline { bytes } => bytes.len() as u64,
61 Self::Local { size_bytes, .. } => *size_bytes,
62 Self::S3 { size_bytes, .. } => *size_bytes,
63 }
64 }
65}
66
67mod serde_bytes_vec {
68 use serde::Deserialize;
69 use serde::Deserializer;
70 use serde::Serializer;
71
72 pub fn serialize<S: Serializer>(bytes: &[u8], ser: S) -> Result<S::Ok, S::Error> {
73 ser.serialize_bytes(bytes)
74 }
75
76 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
77 Vec::<u8>::deserialize(de)
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct SnapshotPointer {
87 pub snapshot_id: String,
88 pub location: SnapshotLocation,
89}
90
91impl SnapshotPointer {
92 pub fn encode(&self) -> Result<Vec<u8>, SnapshotStoreError> {
93 serde_json::to_vec(self).map_err(|err| SnapshotStoreError::Serialize(err.to_string()))
94 }
95
96 pub fn decode(bytes: &[u8]) -> Result<Self, SnapshotStoreError> {
97 serde_json::from_slice(bytes)
98 .map_err(|err| SnapshotStoreError::Deserialize(err.to_string()))
99 }
100}
101
102#[derive(Debug)]
103pub enum SnapshotStoreError {
104 Backend(String),
105 NotFound(String),
106 Integrity(String),
107 Serialize(String),
108 Deserialize(String),
109 Io(io::Error),
110}
111
112impl std::fmt::Display for SnapshotStoreError {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 Self::Backend(m) => write!(f, "snapshot store backend: {m}"),
116 Self::NotFound(m) => write!(f, "snapshot not found: {m}"),
117 Self::Integrity(m) => write!(f, "snapshot integrity: {m}"),
118 Self::Serialize(m) => write!(f, "snapshot serialize: {m}"),
119 Self::Deserialize(m) => write!(f, "snapshot deserialize: {m}"),
120 Self::Io(err) => write!(f, "snapshot io: {err}"),
121 }
122 }
123}
124
125impl std::error::Error for SnapshotStoreError {}
126
127impl From<io::Error> for SnapshotStoreError {
128 fn from(err: io::Error) -> Self {
129 Self::Io(err)
130 }
131}
132
133impl SnapshotStoreError {
134 pub fn into_io(self) -> io::Error {
135 match self {
136 Self::Io(err) => err,
137 other => io::Error::other(other.to_string()),
138 }
139 }
140}
141
142pub type SnapshotStoreFuture<'a, T> =
143 Pin<Box<dyn Future<Output = Result<T, SnapshotStoreError>> + Send + 'a>>;
144
145pub trait SnapshotStore: Send + Sync + Debug {
146 fn upload<'a>(
149 &'a self,
150 key: SnapshotKey,
151 bytes: Vec<u8>,
152 ) -> SnapshotStoreFuture<'a, SnapshotLocation>;
153
154 fn download<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, Vec<u8>>;
156
157 fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()>;
159
160 fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
166 Box::pin(async move { Ok(()) })
167 }
168
169 fn verify_uploaded<'a>(
179 &'a self,
180 _location: &'a SnapshotLocation,
181 ) -> SnapshotStoreFuture<'a, ()> {
182 Box::pin(async move { Ok(()) })
183 }
184}
185
186pub type SharedSnapshotStore = Arc<dyn SnapshotStore>;
187
188pub fn default_snapshot_store() -> SharedSnapshotStore {
190 Arc::new(InlineSnapshotStore)
191}
192
193#[derive(Debug, Default, Clone, Copy)]
195pub struct InlineSnapshotStore;
196
197impl SnapshotStore for InlineSnapshotStore {
198 fn upload<'a>(
199 &'a self,
200 _key: SnapshotKey,
201 bytes: Vec<u8>,
202 ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
203 Box::pin(async move { Ok(SnapshotLocation::Inline { bytes }) })
204 }
205
206 fn download<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, Vec<u8>> {
207 Box::pin(async move {
208 match location {
209 SnapshotLocation::Inline { bytes } => Ok(bytes.clone()),
210 other => Err(SnapshotStoreError::Backend(format!(
211 "inline snapshot store cannot download {other:?}"
212 ))),
213 }
214 })
215 }
216
217 fn delete<'a>(&'a self, _location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
218 Box::pin(async move { Ok(()) })
219 }
220}
221
222#[cfg(not(madsim))]
223mod s3 {
224 use opendal::Operator;
225 use opendal::Scheme;
226
227 use super::SnapshotKey;
228 use super::SnapshotLocation;
229 use super::SnapshotStore;
230 use super::SnapshotStoreError;
231 use super::SnapshotStoreFuture;
232
233 pub struct S3SnapshotStore {
235 operator: Operator,
236 prefix: String,
237 }
238
239 impl std::fmt::Debug for S3SnapshotStore {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 f.debug_struct("S3SnapshotStore")
242 .field("prefix", &self.prefix)
243 .finish_non_exhaustive()
244 }
245 }
246
247 impl S3SnapshotStore {
248 pub fn new(operator: Operator, prefix: impl Into<String>) -> Self {
249 let mut prefix = prefix.into();
250 while prefix.ends_with('/') {
251 prefix.pop();
252 }
253 Self { operator, prefix }
254 }
255
256 pub fn memory_for_tests(prefix: impl Into<String>) -> Result<Self, SnapshotStoreError> {
258 let operator = Operator::via_iter(Scheme::Memory, [])
259 .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
260 Ok(Self::new(operator, prefix))
261 }
262
263 pub fn try_new(
267 config: &crate::ColdConfig,
268 prefix: impl Into<String>,
269 ) -> Result<Self, SnapshotStoreError> {
270 let s3 = config.s3.as_ref().ok_or_else(|| {
271 SnapshotStoreError::Backend("S3 config is required for snapshot s3 backend".into())
272 })?;
273 let bucket = s3.bucket.as_deref().ok_or_else(|| {
274 SnapshotStoreError::Backend("S3 bucket is required for snapshot s3 backend".into())
275 })?;
276 if bucket.trim().is_empty() {
277 return Err(SnapshotStoreError::Backend(
278 "snapshot s3 bucket must not be empty".into(),
279 ));
280 }
281 let mut builder = opendal::services::S3::default().bucket(bucket);
282 if let Some(root) = config.root.as_deref()
283 && !root.trim().is_empty()
284 {
285 builder = builder.root(root);
286 }
287 if let Some(region) = s3.region.as_deref()
288 && !region.trim().is_empty()
289 {
290 builder = builder.region(region);
291 }
292 if let Some(endpoint) = s3.endpoint.as_deref()
293 && !endpoint.trim().is_empty()
294 {
295 builder = builder.endpoint(endpoint);
296 }
297 if let Some(access) = s3.access_key_id.as_deref()
298 && !access.trim().is_empty()
299 {
300 builder = builder.access_key_id(access);
301 }
302 if let Some(secret) = s3.secret_access_key.as_deref()
303 && !secret.trim().is_empty()
304 {
305 builder = builder.secret_access_key(secret);
306 }
307 if let Some(token) = s3.session_token.as_deref()
308 && !token.trim().is_empty()
309 {
310 builder = builder.session_token(token);
311 }
312 let operator = crate::cold_store::with_s3_resilience(
313 Operator::new(builder)
314 .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?
315 .finish(),
316 s3.timeout.as_duration(),
317 s3.max_retries,
318 );
319 Ok(Self::new(operator, prefix))
320 }
321
322 fn object_key(&self, key: &SnapshotKey) -> String {
330 use std::sync::atomic::AtomicU64;
331 use std::sync::atomic::Ordering;
332
333 use crossbeam_utils::CachePadded;
334 static COUNTER: CachePadded<AtomicU64> = CachePadded::new(AtomicU64::new(0));
338 let nonce_nanos = std::time::SystemTime::now()
339 .duration_since(std::time::UNIX_EPOCH)
340 .map(|d| d.as_nanos())
341 .unwrap_or(0);
342 let nonce_seq = COUNTER.fetch_add(1, Ordering::Relaxed);
343 format!(
344 "{}/group-{}/{}-{nonce_nanos:032}-{nonce_seq:020}.snap",
345 self.prefix, key.raft_group_id, key.snapshot_id,
346 )
347 }
348 }
349
350 impl SnapshotStore for S3SnapshotStore {
351 fn upload<'a>(
352 &'a self,
353 key: SnapshotKey,
354 bytes: Vec<u8>,
355 ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
356 Box::pin(async move {
357 let object_key = self.object_key(&key);
358 let size_bytes = bytes.len() as u64;
359 self.operator
360 .write(&object_key, bytes)
361 .await
362 .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
363 Ok(SnapshotLocation::S3 {
364 key: object_key,
365 size_bytes,
366 })
367 })
368 }
369
370 fn download<'a>(
371 &'a self,
372 location: &'a SnapshotLocation,
373 ) -> SnapshotStoreFuture<'a, Vec<u8>> {
374 Box::pin(async move {
375 let SnapshotLocation::S3 { key, size_bytes } = location else {
376 return Err(SnapshotStoreError::Backend(format!(
377 "s3 snapshot store cannot download {location:?}"
378 )));
379 };
380 let buf = self.operator.read(key).await.map_err(|err| {
381 if matches!(err.kind(), opendal::ErrorKind::NotFound) {
382 SnapshotStoreError::NotFound(format!("s3 snapshot missing at {key}"))
383 } else {
384 SnapshotStoreError::Backend(err.to_string())
385 }
386 })?;
387 let bytes = buf.to_vec();
388 if bytes.len() as u64 != *size_bytes {
389 return Err(SnapshotStoreError::Integrity(format!(
390 "s3 snapshot {key} size {} != expected {}",
391 bytes.len(),
392 size_bytes
393 )));
394 }
395 Ok(bytes)
396 })
397 }
398
399 fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
400 Box::pin(async move {
401 let SnapshotLocation::S3 { key, .. } = location else {
402 return Ok(());
403 };
404 match self.operator.delete(key).await {
405 Ok(()) => Ok(()),
406 Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
407 Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
408 }
409 })
410 }
411
412 fn verify_uploaded<'a>(
413 &'a self,
414 location: &'a SnapshotLocation,
415 ) -> SnapshotStoreFuture<'a, ()> {
416 Box::pin(async move {
417 let SnapshotLocation::S3 { key, size_bytes } = location else {
418 return Ok(());
419 };
420 let meta = self.operator.stat(key).await.map_err(|err| {
421 if matches!(err.kind(), opendal::ErrorKind::NotFound) {
422 SnapshotStoreError::NotFound(format!(
423 "s3 snapshot upload verification failed: {key} not present after upload"
424 ))
425 } else {
426 SnapshotStoreError::Backend(err.to_string())
427 }
428 })?;
429 let actual = meta.content_length();
430 if actual != *size_bytes {
431 return Err(SnapshotStoreError::Integrity(format!(
432 "s3 snapshot {key} size mismatch post-upload: stat={actual} expected={size_bytes}"
433 )));
434 }
435 Ok(())
436 })
437 }
438
439 fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
440 Box::pin(async move {
441 let probe = format!("{}/.health-probe", self.prefix);
446 match self.operator.stat(&probe).await {
447 Ok(_) => Ok(()),
448 Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
449 Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
450 }
451 })
452 }
453 }
454}
455
456#[cfg(not(madsim))]
457pub use s3::S3SnapshotStore;
458
459pub fn snapshot_store_from_config(
463 cfg: &ursula_config::RaftSnapshotConfig,
464 cold_cfg: &crate::ColdConfig,
465) -> Result<Option<SharedSnapshotStore>, SnapshotStoreError> {
466 let _ = cold_cfg;
467 match cfg.backend {
468 ursula_config::RaftSnapshotBackend::Inline => Ok(None),
469 #[cfg(not(madsim))]
470 ursula_config::RaftSnapshotBackend::Local => {
471 let root = cfg.local_root.as_ref().ok_or_else(|| {
472 SnapshotStoreError::Backend("snapshot local_root required for local backend".into())
473 })?;
474 let root_str = root.to_string_lossy();
475 if root_str.trim().is_empty() {
476 return Err(SnapshotStoreError::Backend(
477 "snapshot local_root must not be empty".into(),
478 ));
479 }
480 Ok(Some(Arc::new(LocalSnapshotStore::new(root))))
481 }
482 #[cfg(not(madsim))]
483 ursula_config::RaftSnapshotBackend::S3 => {
484 let prefix = cfg.s3_prefix.as_deref().unwrap_or("snapshots");
485 Ok(Some(Arc::new(S3SnapshotStore::try_new(cold_cfg, prefix)?)))
486 }
487 #[cfg(madsim)]
488 ursula_config::RaftSnapshotBackend::Local | ursula_config::RaftSnapshotBackend::S3 => {
489 Err(SnapshotStoreError::Backend(format!(
490 "snapshot backend {:?} has no I/O under madsim; use 'inline'",
491 cfg.backend
492 )))
493 }
494 }
495}
496
497#[cfg(not(madsim))]
498mod local {
499 use std::io;
500 use std::path::PathBuf;
501
502 use super::SnapshotKey;
503 use super::SnapshotLocation;
504 use super::SnapshotStore;
505 use super::SnapshotStoreError;
506 use super::SnapshotStoreFuture;
507
508 #[derive(Debug, Clone)]
510 pub struct LocalSnapshotStore {
511 root: PathBuf,
512 }
513
514 impl LocalSnapshotStore {
515 pub fn new(root: impl Into<PathBuf>) -> Self {
516 Self { root: root.into() }
517 }
518
519 fn path_for(&self, key: SnapshotKey) -> PathBuf {
520 self.root
521 .join(format!("group-{}", key.raft_group_id))
522 .join(key.filename())
523 }
524 }
525
526 impl SnapshotStore for LocalSnapshotStore {
527 fn upload<'a>(
528 &'a self,
529 key: SnapshotKey,
530 bytes: Vec<u8>,
531 ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
532 Box::pin(async move {
533 let path = self.path_for(key);
534 if let Some(parent) = path.parent() {
535 tokio::fs::create_dir_all(parent).await?;
536 }
537 let size_bytes = bytes.len() as u64;
538 tokio::fs::write(&path, &bytes).await?;
539 Ok(SnapshotLocation::Local { path, size_bytes })
540 })
541 }
542
543 fn download<'a>(
544 &'a self,
545 location: &'a SnapshotLocation,
546 ) -> SnapshotStoreFuture<'a, Vec<u8>> {
547 Box::pin(async move {
548 let SnapshotLocation::Local { path, size_bytes } = location else {
549 return Err(SnapshotStoreError::Backend(format!(
550 "local snapshot store cannot download {location:?}"
551 )));
552 };
553 let bytes = tokio::fs::read(path).await.map_err(|err| {
554 if err.kind() == io::ErrorKind::NotFound {
555 SnapshotStoreError::NotFound(format!(
556 "local snapshot missing at {}",
557 path.display()
558 ))
559 } else {
560 SnapshotStoreError::Io(err)
561 }
562 })?;
563 if bytes.len() as u64 != *size_bytes {
564 return Err(SnapshotStoreError::Integrity(format!(
565 "local snapshot at {} size {} != expected {}",
566 path.display(),
567 bytes.len(),
568 size_bytes
569 )));
570 }
571 Ok(bytes)
572 })
573 }
574
575 fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
576 Box::pin(async move {
577 let SnapshotLocation::Local { path, .. } = location else {
578 return Ok(());
579 };
580 match tokio::fs::remove_file(path).await {
581 Ok(()) => Ok(()),
582 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
583 Err(err) => Err(SnapshotStoreError::Io(err)),
584 }
585 })
586 }
587 }
588}
589
590#[cfg(not(madsim))]
591pub use local::LocalSnapshotStore;
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596
597 fn test_key(raft_group_id: u32, snapshot_id: &str) -> SnapshotKey {
598 SnapshotKey {
599 raft_group_id,
600 snapshot_id: snapshot_id.to_owned(),
601 }
602 }
603
604 #[tokio::test]
605 async fn inline_roundtrip() {
606 let store = InlineSnapshotStore;
607 let key = test_key(0, "group-0-T1-N1-100");
608 let loc = store.upload(key, b"hello world".to_vec()).await.unwrap();
609 assert!(matches!(loc, SnapshotLocation::Inline { .. }));
610 let bytes = store.download(&loc).await.unwrap();
611 assert_eq!(bytes, b"hello world");
612 store.delete(&loc).await.unwrap();
613 }
614
615 #[tokio::test]
616 async fn inline_rejects_other_location() {
617 let store = InlineSnapshotStore;
618 let loc = SnapshotLocation::Local {
619 path: PathBuf::from("/tmp/nope"),
620 size_bytes: 4,
621 };
622 assert!(matches!(
623 store.download(&loc).await,
624 Err(SnapshotStoreError::Backend(_))
625 ));
626 }
627
628 #[test]
629 fn pointer_encode_decode_inline() {
630 let pointer = SnapshotPointer {
631 snapshot_id: "group-0-1-100".into(),
632 location: SnapshotLocation::Inline {
633 bytes: vec![1, 2, 3, 4],
634 },
635 };
636 let bytes = pointer.encode().unwrap();
637 let back = SnapshotPointer::decode(&bytes).unwrap();
638 assert_eq!(back.snapshot_id, pointer.snapshot_id);
639 match back.location {
640 SnapshotLocation::Inline { bytes } => assert_eq!(bytes, vec![1, 2, 3, 4]),
641 other => panic!("unexpected location: {other:?}"),
642 }
643 }
644
645 #[test]
646 fn pointer_encode_decode_local() {
647 let pointer = SnapshotPointer {
648 snapshot_id: "group-7-2-500".into(),
649 location: SnapshotLocation::Local {
650 path: PathBuf::from("/var/snap/group-7-term-2-log-500.snap"),
651 size_bytes: 12345,
652 },
653 };
654 let bytes = pointer.encode().unwrap();
655 let back = SnapshotPointer::decode(&bytes).unwrap();
656 assert_eq!(back.snapshot_id, pointer.snapshot_id);
657 assert_eq!(back.location.size_hint(), 12345);
658 }
659
660 #[cfg(not(madsim))]
661 #[tokio::test]
662 async fn local_roundtrip() {
663 let dir = tempfile::tempdir().unwrap();
664 let store = LocalSnapshotStore::new(dir.path());
665 let key = test_key(7, "group-7-T2-N1-500");
666 let loc = store
667 .upload(key, b"some snapshot bytes".to_vec())
668 .await
669 .unwrap();
670 let bytes = store.download(&loc).await.unwrap();
671 assert_eq!(bytes, b"some snapshot bytes");
672 store.delete(&loc).await.unwrap();
673 let again = store.download(&loc).await;
674 assert!(matches!(again, Err(SnapshotStoreError::NotFound(_))));
675 store.delete(&loc).await.unwrap();
677 }
678
679 #[cfg(not(madsim))]
680 #[tokio::test]
681 async fn s3_memory_roundtrip() {
682 let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
683 let key = test_key(3, "group-3-T5-N2-9876");
684 let loc = store
685 .upload(key, b"raw snapshot bytes".to_vec())
686 .await
687 .unwrap();
688 match &loc {
689 SnapshotLocation::S3 { key, size_bytes } => {
690 assert!(key.starts_with("snapshots/group-3/"));
691 assert_eq!(*size_bytes, b"raw snapshot bytes".len() as u64);
692 }
693 other => panic!("expected S3 location, got {other:?}"),
694 }
695 let bytes = store.download(&loc).await.unwrap();
696 assert_eq!(bytes, b"raw snapshot bytes");
697 store.delete(&loc).await.unwrap();
698 assert!(matches!(
699 store.download(&loc).await,
700 Err(SnapshotStoreError::NotFound(_))
701 ));
702 store.delete(&loc).await.unwrap();
704 }
705
706 #[cfg(not(madsim))]
707 #[tokio::test]
708 async fn s3_two_uploads_with_same_snapshot_id_get_different_keys() {
709 let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
714 let key1 = test_key(4, "group-4-T18-N3-264150");
715 let key2 = test_key(4, "group-4-T18-N3-264150");
716 let loc1 = store.upload(key1, b"body1".to_vec()).await.unwrap();
717 let loc2 = store.upload(key2, b"body2".to_vec()).await.unwrap();
718 let (k1, k2) = match (&loc1, &loc2) {
719 (SnapshotLocation::S3 { key: k1, .. }, SnapshotLocation::S3 { key: k2, .. }) => {
720 (k1.clone(), k2.clone())
721 }
722 _ => panic!("expected S3 locations"),
723 };
724 assert_ne!(k1, k2, "same snapshot_id must yield distinct S3 keys");
725 assert_eq!(store.download(&loc1).await.unwrap(), b"body1");
728 assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
729 store.delete(&loc1).await.unwrap();
730 assert!(matches!(
731 store.download(&loc1).await,
732 Err(SnapshotStoreError::NotFound(_))
733 ));
734 assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
735 }
736
737 #[cfg(not(madsim))]
738 #[tokio::test]
739 async fn s3_verify_uploaded_catches_missing_object() {
740 let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
741 let key = test_key(2, "group-2-T1-N1-7");
742 let loc = store.upload(key, b"payload".to_vec()).await.unwrap();
743 store.verify_uploaded(&loc).await.unwrap();
745 store.delete(&loc).await.unwrap();
749 let err = store.verify_uploaded(&loc).await.unwrap_err();
750 assert!(
751 matches!(err, SnapshotStoreError::NotFound(_)),
752 "expected NotFound after delete, got {err:?}"
753 );
754 }
755
756 #[cfg(not(madsim))]
757 #[tokio::test]
758 async fn local_integrity_detects_size_mismatch() {
759 let dir = tempfile::tempdir().unwrap();
760 let store = LocalSnapshotStore::new(dir.path());
761 let key = test_key(1, "group-1-T1-N1-1");
762 let loc = store.upload(key, b"abcd".to_vec()).await.unwrap();
763 let SnapshotLocation::Local { path, .. } = &loc else {
764 unreachable!()
765 };
766 tokio::fs::write(path, b"abcde").await.unwrap();
767 let result = store.download(&loc).await;
768 assert!(matches!(result, Err(SnapshotStoreError::Integrity(_))));
769 }
770}