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 s3_from_env() -> Result<Self, SnapshotStoreError> {
267 let bucket = std::env::var("URSULA_COLD_S3_BUCKET").map_err(|_| {
268 SnapshotStoreError::Backend(
269 "URSULA_COLD_S3_BUCKET is required for snapshot s3 backend".into(),
270 )
271 })?;
272 if bucket.trim().is_empty() {
273 return Err(SnapshotStoreError::Backend(
274 "snapshot s3 bucket must not be empty".into(),
275 ));
276 }
277 let mut builder = opendal::services::S3::default().bucket(&bucket);
278 if let Ok(root) = std::env::var("URSULA_COLD_ROOT")
281 && !root.trim().is_empty()
282 {
283 builder = builder.root(&root);
284 }
285 if let Ok(region) = std::env::var("URSULA_COLD_S3_REGION")
286 && !region.trim().is_empty()
287 {
288 builder = builder.region(®ion);
289 }
290 if let Ok(endpoint) = std::env::var("URSULA_COLD_S3_ENDPOINT")
291 && !endpoint.trim().is_empty()
292 {
293 builder = builder.endpoint(&endpoint);
294 }
295 if let Ok(access) = std::env::var("URSULA_COLD_S3_ACCESS_KEY_ID")
296 && !access.trim().is_empty()
297 {
298 builder = builder.access_key_id(&access);
299 }
300 if let Ok(secret) = std::env::var("URSULA_COLD_S3_SECRET_ACCESS_KEY")
301 && !secret.trim().is_empty()
302 {
303 builder = builder.secret_access_key(&secret);
304 }
305 if let Ok(token) = std::env::var("URSULA_COLD_S3_SESSION_TOKEN")
306 && !token.trim().is_empty()
307 {
308 builder = builder.session_token(&token);
309 }
310 let operator = crate::cold_store::with_s3_resilience(
311 Operator::new(builder)
312 .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?
313 .finish(),
314 );
315 let prefix = std::env::var("URSULA_SNAPSHOT_S3_PREFIX")
316 .unwrap_or_else(|_| "snapshots".to_owned());
317 Ok(Self::new(operator, prefix))
318 }
319
320 fn object_key(&self, key: &SnapshotKey) -> String {
328 use std::sync::atomic::AtomicU64;
329 use std::sync::atomic::Ordering;
330
331 use crossbeam_utils::CachePadded;
332 static COUNTER: CachePadded<AtomicU64> = CachePadded::new(AtomicU64::new(0));
336 let nonce_nanos = std::time::SystemTime::now()
337 .duration_since(std::time::UNIX_EPOCH)
338 .map(|d| d.as_nanos())
339 .unwrap_or(0);
340 let nonce_seq = COUNTER.fetch_add(1, Ordering::Relaxed);
341 format!(
342 "{}/group-{}/{}-{nonce_nanos:032}-{nonce_seq:020}.snap",
343 self.prefix, key.raft_group_id, key.snapshot_id,
344 )
345 }
346 }
347
348 impl SnapshotStore for S3SnapshotStore {
349 fn upload<'a>(
350 &'a self,
351 key: SnapshotKey,
352 bytes: Vec<u8>,
353 ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
354 Box::pin(async move {
355 let object_key = self.object_key(&key);
356 let size_bytes = bytes.len() as u64;
357 self.operator
358 .write(&object_key, bytes)
359 .await
360 .map_err(|err| SnapshotStoreError::Backend(err.to_string()))?;
361 Ok(SnapshotLocation::S3 {
362 key: object_key,
363 size_bytes,
364 })
365 })
366 }
367
368 fn download<'a>(
369 &'a self,
370 location: &'a SnapshotLocation,
371 ) -> SnapshotStoreFuture<'a, Vec<u8>> {
372 Box::pin(async move {
373 let SnapshotLocation::S3 { key, size_bytes } = location else {
374 return Err(SnapshotStoreError::Backend(format!(
375 "s3 snapshot store cannot download {location:?}"
376 )));
377 };
378 let buf = self.operator.read(key).await.map_err(|err| {
379 if matches!(err.kind(), opendal::ErrorKind::NotFound) {
380 SnapshotStoreError::NotFound(format!("s3 snapshot missing at {key}"))
381 } else {
382 SnapshotStoreError::Backend(err.to_string())
383 }
384 })?;
385 let bytes = buf.to_vec();
386 if bytes.len() as u64 != *size_bytes {
387 return Err(SnapshotStoreError::Integrity(format!(
388 "s3 snapshot {key} size {} != expected {}",
389 bytes.len(),
390 size_bytes
391 )));
392 }
393 Ok(bytes)
394 })
395 }
396
397 fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
398 Box::pin(async move {
399 let SnapshotLocation::S3 { key, .. } = location else {
400 return Ok(());
401 };
402 match self.operator.delete(key).await {
403 Ok(()) => Ok(()),
404 Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
405 Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
406 }
407 })
408 }
409
410 fn verify_uploaded<'a>(
411 &'a self,
412 location: &'a SnapshotLocation,
413 ) -> SnapshotStoreFuture<'a, ()> {
414 Box::pin(async move {
415 let SnapshotLocation::S3 { key, size_bytes } = location else {
416 return Ok(());
417 };
418 let meta = self.operator.stat(key).await.map_err(|err| {
419 if matches!(err.kind(), opendal::ErrorKind::NotFound) {
420 SnapshotStoreError::NotFound(format!(
421 "s3 snapshot upload verification failed: {key} not present after upload"
422 ))
423 } else {
424 SnapshotStoreError::Backend(err.to_string())
425 }
426 })?;
427 let actual = meta.content_length();
428 if actual != *size_bytes {
429 return Err(SnapshotStoreError::Integrity(format!(
430 "s3 snapshot {key} size mismatch post-upload: stat={actual} expected={size_bytes}"
431 )));
432 }
433 Ok(())
434 })
435 }
436
437 fn health_check(&self) -> SnapshotStoreFuture<'_, ()> {
438 Box::pin(async move {
439 let probe = format!("{}/.health-probe", self.prefix);
444 match self.operator.stat(&probe).await {
445 Ok(_) => Ok(()),
446 Err(err) if matches!(err.kind(), opendal::ErrorKind::NotFound) => Ok(()),
447 Err(err) => Err(SnapshotStoreError::Backend(err.to_string())),
448 }
449 })
450 }
451 }
452}
453
454#[cfg(not(madsim))]
455pub use s3::S3SnapshotStore;
456
457pub fn snapshot_store_from_env() -> Result<Option<SharedSnapshotStore>, SnapshotStoreError> {
464 let backend = std::env::var("URSULA_SNAPSHOT_BACKEND")
465 .unwrap_or_else(|_| "inline".to_owned())
466 .to_ascii_lowercase();
467 match backend.as_str() {
468 "inline" | "default" | "" => Ok(None),
469 #[cfg(not(madsim))]
470 "local" => {
471 let root = std::env::var("URSULA_SNAPSHOT_LOCAL_ROOT").map_err(|_| {
472 SnapshotStoreError::Backend(
473 "URSULA_SNAPSHOT_LOCAL_ROOT is required for snapshot local backend".into(),
474 )
475 })?;
476 if root.trim().is_empty() {
477 return Err(SnapshotStoreError::Backend(
478 "URSULA_SNAPSHOT_LOCAL_ROOT must not be empty".into(),
479 ));
480 }
481 Ok(Some(Arc::new(LocalSnapshotStore::new(root))))
482 }
483 #[cfg(not(madsim))]
484 "s3" => Ok(Some(Arc::new(S3SnapshotStore::s3_from_env()?))),
485 #[cfg(madsim)]
486 "local" | "s3" => Err(SnapshotStoreError::Backend(format!(
487 "URSULA_SNAPSHOT_BACKEND '{backend}' has no I/O under madsim; use 'inline'"
488 ))),
489 other => Err(SnapshotStoreError::Backend(format!(
490 "unsupported URSULA_SNAPSHOT_BACKEND '{other}' (expected inline | local | s3)"
491 ))),
492 }
493}
494
495#[cfg(not(madsim))]
496mod local {
497 use std::io;
498 use std::path::PathBuf;
499
500 use super::SnapshotKey;
501 use super::SnapshotLocation;
502 use super::SnapshotStore;
503 use super::SnapshotStoreError;
504 use super::SnapshotStoreFuture;
505
506 #[derive(Debug, Clone)]
508 pub struct LocalSnapshotStore {
509 root: PathBuf,
510 }
511
512 impl LocalSnapshotStore {
513 pub fn new(root: impl Into<PathBuf>) -> Self {
514 Self { root: root.into() }
515 }
516
517 fn path_for(&self, key: SnapshotKey) -> PathBuf {
518 self.root
519 .join(format!("group-{}", key.raft_group_id))
520 .join(key.filename())
521 }
522 }
523
524 impl SnapshotStore for LocalSnapshotStore {
525 fn upload<'a>(
526 &'a self,
527 key: SnapshotKey,
528 bytes: Vec<u8>,
529 ) -> SnapshotStoreFuture<'a, SnapshotLocation> {
530 Box::pin(async move {
531 let path = self.path_for(key);
532 if let Some(parent) = path.parent() {
533 tokio::fs::create_dir_all(parent).await?;
534 }
535 let size_bytes = bytes.len() as u64;
536 tokio::fs::write(&path, &bytes).await?;
537 Ok(SnapshotLocation::Local { path, size_bytes })
538 })
539 }
540
541 fn download<'a>(
542 &'a self,
543 location: &'a SnapshotLocation,
544 ) -> SnapshotStoreFuture<'a, Vec<u8>> {
545 Box::pin(async move {
546 let SnapshotLocation::Local { path, size_bytes } = location else {
547 return Err(SnapshotStoreError::Backend(format!(
548 "local snapshot store cannot download {location:?}"
549 )));
550 };
551 let bytes = tokio::fs::read(path).await.map_err(|err| {
552 if err.kind() == io::ErrorKind::NotFound {
553 SnapshotStoreError::NotFound(format!(
554 "local snapshot missing at {}",
555 path.display()
556 ))
557 } else {
558 SnapshotStoreError::Io(err)
559 }
560 })?;
561 if bytes.len() as u64 != *size_bytes {
562 return Err(SnapshotStoreError::Integrity(format!(
563 "local snapshot at {} size {} != expected {}",
564 path.display(),
565 bytes.len(),
566 size_bytes
567 )));
568 }
569 Ok(bytes)
570 })
571 }
572
573 fn delete<'a>(&'a self, location: &'a SnapshotLocation) -> SnapshotStoreFuture<'a, ()> {
574 Box::pin(async move {
575 let SnapshotLocation::Local { path, .. } = location else {
576 return Ok(());
577 };
578 match tokio::fs::remove_file(path).await {
579 Ok(()) => Ok(()),
580 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
581 Err(err) => Err(SnapshotStoreError::Io(err)),
582 }
583 })
584 }
585 }
586}
587
588#[cfg(not(madsim))]
589pub use local::LocalSnapshotStore;
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594
595 fn test_key(raft_group_id: u32, snapshot_id: &str) -> SnapshotKey {
596 SnapshotKey {
597 raft_group_id,
598 snapshot_id: snapshot_id.to_owned(),
599 }
600 }
601
602 #[tokio::test]
603 async fn inline_roundtrip() {
604 let store = InlineSnapshotStore;
605 let key = test_key(0, "group-0-T1-N1-100");
606 let loc = store.upload(key, b"hello world".to_vec()).await.unwrap();
607 assert!(matches!(loc, SnapshotLocation::Inline { .. }));
608 let bytes = store.download(&loc).await.unwrap();
609 assert_eq!(bytes, b"hello world");
610 store.delete(&loc).await.unwrap();
611 }
612
613 #[tokio::test]
614 async fn inline_rejects_other_location() {
615 let store = InlineSnapshotStore;
616 let loc = SnapshotLocation::Local {
617 path: PathBuf::from("/tmp/nope"),
618 size_bytes: 4,
619 };
620 assert!(matches!(
621 store.download(&loc).await,
622 Err(SnapshotStoreError::Backend(_))
623 ));
624 }
625
626 #[test]
627 fn pointer_encode_decode_inline() {
628 let pointer = SnapshotPointer {
629 snapshot_id: "group-0-1-100".into(),
630 location: SnapshotLocation::Inline {
631 bytes: vec![1, 2, 3, 4],
632 },
633 };
634 let bytes = pointer.encode().unwrap();
635 let back = SnapshotPointer::decode(&bytes).unwrap();
636 assert_eq!(back.snapshot_id, pointer.snapshot_id);
637 match back.location {
638 SnapshotLocation::Inline { bytes } => assert_eq!(bytes, vec![1, 2, 3, 4]),
639 other => panic!("unexpected location: {other:?}"),
640 }
641 }
642
643 #[test]
644 fn pointer_encode_decode_local() {
645 let pointer = SnapshotPointer {
646 snapshot_id: "group-7-2-500".into(),
647 location: SnapshotLocation::Local {
648 path: PathBuf::from("/var/snap/group-7-term-2-log-500.snap"),
649 size_bytes: 12345,
650 },
651 };
652 let bytes = pointer.encode().unwrap();
653 let back = SnapshotPointer::decode(&bytes).unwrap();
654 assert_eq!(back.snapshot_id, pointer.snapshot_id);
655 assert_eq!(back.location.size_hint(), 12345);
656 }
657
658 #[cfg(not(madsim))]
659 #[tokio::test]
660 async fn local_roundtrip() {
661 let dir = tempfile::tempdir().unwrap();
662 let store = LocalSnapshotStore::new(dir.path());
663 let key = test_key(7, "group-7-T2-N1-500");
664 let loc = store
665 .upload(key, b"some snapshot bytes".to_vec())
666 .await
667 .unwrap();
668 let bytes = store.download(&loc).await.unwrap();
669 assert_eq!(bytes, b"some snapshot bytes");
670 store.delete(&loc).await.unwrap();
671 let again = store.download(&loc).await;
672 assert!(matches!(again, Err(SnapshotStoreError::NotFound(_))));
673 store.delete(&loc).await.unwrap();
675 }
676
677 #[cfg(not(madsim))]
678 #[tokio::test]
679 async fn s3_memory_roundtrip() {
680 let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
681 let key = test_key(3, "group-3-T5-N2-9876");
682 let loc = store
683 .upload(key, b"raw snapshot bytes".to_vec())
684 .await
685 .unwrap();
686 match &loc {
687 SnapshotLocation::S3 { key, size_bytes } => {
688 assert!(key.starts_with("snapshots/group-3/"));
689 assert_eq!(*size_bytes, b"raw snapshot bytes".len() as u64);
690 }
691 other => panic!("expected S3 location, got {other:?}"),
692 }
693 let bytes = store.download(&loc).await.unwrap();
694 assert_eq!(bytes, b"raw snapshot bytes");
695 store.delete(&loc).await.unwrap();
696 assert!(matches!(
697 store.download(&loc).await,
698 Err(SnapshotStoreError::NotFound(_))
699 ));
700 store.delete(&loc).await.unwrap();
702 }
703
704 #[cfg(not(madsim))]
705 #[tokio::test]
706 async fn s3_two_uploads_with_same_snapshot_id_get_different_keys() {
707 let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
712 let key1 = test_key(4, "group-4-T18-N3-264150");
713 let key2 = test_key(4, "group-4-T18-N3-264150");
714 let loc1 = store.upload(key1, b"body1".to_vec()).await.unwrap();
715 let loc2 = store.upload(key2, b"body2".to_vec()).await.unwrap();
716 let (k1, k2) = match (&loc1, &loc2) {
717 (SnapshotLocation::S3 { key: k1, .. }, SnapshotLocation::S3 { key: k2, .. }) => {
718 (k1.clone(), k2.clone())
719 }
720 _ => panic!("expected S3 locations"),
721 };
722 assert_ne!(k1, k2, "same snapshot_id must yield distinct S3 keys");
723 assert_eq!(store.download(&loc1).await.unwrap(), b"body1");
726 assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
727 store.delete(&loc1).await.unwrap();
728 assert!(matches!(
729 store.download(&loc1).await,
730 Err(SnapshotStoreError::NotFound(_))
731 ));
732 assert_eq!(store.download(&loc2).await.unwrap(), b"body2");
733 }
734
735 #[cfg(not(madsim))]
736 #[tokio::test]
737 async fn s3_verify_uploaded_catches_missing_object() {
738 let store = S3SnapshotStore::memory_for_tests("snapshots").unwrap();
739 let key = test_key(2, "group-2-T1-N1-7");
740 let loc = store.upload(key, b"payload".to_vec()).await.unwrap();
741 store.verify_uploaded(&loc).await.unwrap();
743 store.delete(&loc).await.unwrap();
747 let err = store.verify_uploaded(&loc).await.unwrap_err();
748 assert!(
749 matches!(err, SnapshotStoreError::NotFound(_)),
750 "expected NotFound after delete, got {err:?}"
751 );
752 }
753
754 #[cfg(not(madsim))]
755 #[tokio::test]
756 async fn snapshot_store_from_env_inline_default() {
757 let prev = std::env::var("URSULA_SNAPSHOT_BACKEND").ok();
763 unsafe {
767 std::env::remove_var("URSULA_SNAPSHOT_BACKEND");
768 }
769 let result = snapshot_store_from_env().unwrap();
770 assert!(result.is_none());
771 if let Some(prev) = prev {
772 unsafe {
773 std::env::set_var("URSULA_SNAPSHOT_BACKEND", prev);
774 }
775 }
776 }
777
778 #[cfg(not(madsim))]
779 #[tokio::test]
780 async fn local_integrity_detects_size_mismatch() {
781 let dir = tempfile::tempdir().unwrap();
782 let store = LocalSnapshotStore::new(dir.path());
783 let key = test_key(1, "group-1-T1-N1-1");
784 let loc = store.upload(key, b"abcd".to_vec()).await.unwrap();
785 let SnapshotLocation::Local { path, .. } = &loc else {
786 unreachable!()
787 };
788 tokio::fs::write(path, b"abcde").await.unwrap();
789 let result = store.download(&loc).await;
790 assert!(matches!(result, Err(SnapshotStoreError::Integrity(_))));
791 }
792}