Skip to main content

proof_engine/save/
cloud.rs

1//! Cloud save synchronisation: upload/download queuing, conflict resolution,
2//! offline buffering, encryption, and rotating local backups.
3//!
4//! No real network I/O is performed — all "remote" state is simulated in
5//! memory so the module compiles and tests without external dependencies.
6
7use std::collections::{HashMap, VecDeque};
8
9// ─────────────────────────────────────────────────────────────────────────────
10//  CloudProvider
11// ─────────────────────────────────────────────────────────────────────────────
12
13/// Which cloud backend to use.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum CloudProvider {
16    None,
17    SteamCloud,
18    EpicOnline,
19    CustomServer(String),
20}
21
22impl CloudProvider {
23    pub fn is_enabled(&self) -> bool {
24        !matches!(self, CloudProvider::None)
25    }
26
27    pub fn display_name(&self) -> String {
28        match self {
29            CloudProvider::None              => "None".into(),
30            CloudProvider::SteamCloud        => "Steam Cloud".into(),
31            CloudProvider::EpicOnline        => "Epic Online".into(),
32            CloudProvider::CustomServer(url) => format!("Custom ({})", url),
33        }
34    }
35}
36
37// ─────────────────────────────────────────────────────────────────────────────
38//  CloudSaveEntry
39// ─────────────────────────────────────────────────────────────────────────────
40
41/// A single save slot entry stored in the cloud.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct CloudSaveEntry {
44    pub slot_id: u32,
45    pub user_id: String,
46    pub save_data: Vec<u8>,
47    pub timestamp: u64,
48    pub checksum: u32,
49    pub size_bytes: usize,
50    pub conflict_resolved: bool,
51}
52
53impl CloudSaveEntry {
54    pub fn new(slot_id: u32, user_id: impl Into<String>, save_data: Vec<u8>, timestamp: u64) -> Self {
55        let checksum = simple_checksum(&save_data);
56        let size_bytes = save_data.len();
57        Self { slot_id, user_id: user_id.into(), save_data, timestamp, checksum, size_bytes, conflict_resolved: false }
58    }
59
60    pub fn verify_checksum(&self) -> bool {
61        simple_checksum(&self.save_data) == self.checksum
62    }
63}
64
65fn simple_checksum(data: &[u8]) -> u32 {
66    let mut crc = 0u32;
67    for &b in data {
68        crc = crc.wrapping_add(b as u32).rotate_left(3);
69    }
70    crc
71}
72
73// ─────────────────────────────────────────────────────────────────────────────
74//  ConflictResolution
75// ─────────────────────────────────────────────────────────────────────────────
76
77/// How to resolve a conflict between a local and remote save.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ConflictResolution {
80    LocalWins,
81    RemoteWins,
82    MergeByTimestamp,
83    MergeByVersion,
84    UserChoice,
85}
86
87// ─────────────────────────────────────────────────────────────────────────────
88//  CloudSyncState
89// ─────────────────────────────────────────────────────────────────────────────
90
91/// Current synchronisation state of the cloud client.
92#[derive(Debug, Clone)]
93pub enum CloudSyncState {
94    Idle,
95    Uploading(f32),
96    Downloading(f32),
97    Conflict { local: CloudSaveEntry, remote: CloudSaveEntry },
98    SyncComplete,
99    Error(String),
100}
101
102// ─────────────────────────────────────────────────────────────────────────────
103//  CloudMetadata / CloudSaveStats
104// ─────────────────────────────────────────────────────────────────────────────
105
106/// Aggregate metadata about the cloud storage account.
107#[derive(Debug, Clone, Default)]
108pub struct CloudMetadata {
109    pub last_sync_time: u64,
110    pub total_slots: u32,
111    pub used_bytes: u64,
112    pub quota_bytes: u64,
113}
114
115impl CloudMetadata {
116    pub fn usage_fraction(&self) -> f32 {
117        if self.quota_bytes == 0 { return 0.0; }
118        (self.used_bytes as f32) / (self.quota_bytes as f32)
119    }
120
121    pub fn has_space(&self, needed: u64) -> bool {
122        self.used_bytes + needed <= self.quota_bytes
123    }
124}
125
126/// Cumulative statistics for a session.
127#[derive(Debug, Clone, Default)]
128pub struct CloudSaveStats {
129    pub uploads: u64,
130    pub downloads: u64,
131    pub conflicts_resolved: u64,
132    pub bytes_transferred: u64,
133}
134
135impl CloudSaveStats {
136    pub fn record_upload(&mut self, bytes: usize) {
137        self.uploads += 1;
138        self.bytes_transferred += bytes as u64;
139    }
140    pub fn record_download(&mut self, bytes: usize) {
141        self.downloads += 1;
142        self.bytes_transferred += bytes as u64;
143    }
144    pub fn record_conflict_resolved(&mut self) {
145        self.conflicts_resolved += 1;
146    }
147}
148
149// ─────────────────────────────────────────────────────────────────────────────
150//  OfflineQueue
151// ─────────────────────────────────────────────────────────────────────────────
152
153/// Pending cloud operation recorded while offline.
154#[derive(Debug, Clone)]
155pub enum PendingOp {
156    Upload { slot_id: u32, data: Vec<u8>, timestamp: u64 },
157    Download { slot_id: u32 },
158}
159
160/// Stores pending cloud operations when offline.  Max 100 entries; oldest is
161/// evicted when full.
162#[derive(Debug, Default)]
163pub struct OfflineQueue {
164    ops: VecDeque<PendingOp>,
165}
166
167impl OfflineQueue {
168    const MAX_OPS: usize = 100;
169
170    pub fn new() -> Self {
171        Self { ops: VecDeque::new() }
172    }
173
174    pub fn push(&mut self, op: PendingOp) {
175        if self.ops.len() >= Self::MAX_OPS {
176            self.ops.pop_front(); // evict oldest
177        }
178        self.ops.push_back(op);
179    }
180
181    pub fn drain(&mut self) -> Vec<PendingOp> {
182        self.ops.drain(..).collect()
183    }
184
185    pub fn len(&self) -> usize {
186        self.ops.len()
187    }
188
189    pub fn is_empty(&self) -> bool {
190        self.ops.is_empty()
191    }
192
193    pub fn is_full(&self) -> bool {
194        self.ops.len() >= Self::MAX_OPS
195    }
196}
197
198// ─────────────────────────────────────────────────────────────────────────────
199//  SaveEncryption
200// ─────────────────────────────────────────────────────────────────────────────
201
202/// XOR-cipher with key derived from the user ID.
203///
204/// NOT cryptographically secure — this is a structural demonstration only.
205pub struct SaveEncryption;
206
207impl SaveEncryption {
208    /// Derive a repeating key from the user ID.
209    fn derive_key(user_id: &str) -> Vec<u8> {
210        let base = user_id.as_bytes();
211        if base.is_empty() {
212            return vec![0xAB];
213        }
214        // Mix bytes together deterministically
215        let mut key = base.to_vec();
216        for i in 1..32 {
217            let prev = key[i - 1];
218            let next = base[i % base.len()];
219            key.push(prev.wrapping_add(next).wrapping_mul(0x45).wrapping_add(0x12));
220        }
221        key
222    }
223
224    /// Encrypt `data` using a key derived from `user_id`.
225    pub fn encrypt(data: &[u8], user_id: &str) -> Vec<u8> {
226        let key = Self::derive_key(user_id);
227        data.iter()
228            .enumerate()
229            .map(|(i, &b)| b ^ key[i % key.len()])
230            .collect()
231    }
232
233    /// Decrypt `data` using a key derived from `user_id`.
234    pub fn decrypt(data: &[u8], user_id: &str) -> Result<Vec<u8>, String> {
235        // XOR is self-inverse
236        Ok(Self::encrypt(data, user_id))
237    }
238}
239
240// ─────────────────────────────────────────────────────────────────────────────
241//  BackupManager
242// ─────────────────────────────────────────────────────────────────────────────
243
244/// Entry in the backup list for a slot.
245#[derive(Debug, Clone)]
246pub struct BackupEntry {
247    pub backup_idx: usize,
248    pub timestamp: u64,
249    pub size_bytes: usize,
250    pub checksum: u32,
251}
252
253/// Rotating local backups — up to 5 per slot.
254#[derive(Debug, Default)]
255pub struct BackupManager {
256    /// slot_id → ordered list of backups (oldest first)
257    backups: HashMap<u32, Vec<(u64, Vec<u8>)>>,
258}
259
260impl BackupManager {
261    const MAX_BACKUPS: usize = 5;
262
263    pub fn new() -> Self {
264        Self { backups: HashMap::new() }
265    }
266
267    /// Create a backup of `data` for the given slot.
268    pub fn backup(&mut self, slot_id: u32, data: Vec<u8>, timestamp: u64) {
269        let slot_backups = self.backups.entry(slot_id).or_default();
270        if slot_backups.len() >= Self::MAX_BACKUPS {
271            slot_backups.remove(0); // evict oldest
272        }
273        slot_backups.push((timestamp, data));
274    }
275
276    /// Restore a backup for a slot by index (0 = oldest).
277    pub fn restore_backup(&self, slot_id: u32, backup_idx: usize) -> Option<Vec<u8>> {
278        let slot_backups = self.backups.get(&slot_id)?;
279        slot_backups.get(backup_idx).map(|(_, data)| data.clone())
280    }
281
282    /// List all backups for a slot.
283    pub fn list_backups(&self, slot_id: u32) -> Vec<BackupEntry> {
284        match self.backups.get(&slot_id) {
285            None => Vec::new(),
286            Some(backups) => backups
287                .iter()
288                .enumerate()
289                .map(|(i, (ts, data))| BackupEntry {
290                    backup_idx: i,
291                    timestamp: *ts,
292                    size_bytes: data.len(),
293                    checksum: simple_checksum(data),
294                })
295                .collect(),
296        }
297    }
298
299    /// Number of backups for a slot.
300    pub fn backup_count(&self, slot_id: u32) -> usize {
301        self.backups.get(&slot_id).map_or(0, |v| v.len())
302    }
303}
304
305// ─────────────────────────────────────────────────────────────────────────────
306//  CloudSaveClient
307// ─────────────────────────────────────────────────────────────────────────────
308
309/// Manages upload/download queue, conflict detection, retry with exponential
310/// backoff (3 attempts), and a concurrent upload limit of 2.
311pub struct CloudSaveClient {
312    pub provider: CloudProvider,
313    pub user_id: String,
314    pub state: CloudSyncState,
315    pub stats: CloudSaveStats,
316    pub metadata: CloudMetadata,
317    pub conflict_resolution: ConflictResolution,
318
319    /// Simulated remote storage: slot_id → entry
320    remote_store: HashMap<u32, CloudSaveEntry>,
321    /// Local working copies: slot_id → data
322    local_store: HashMap<u32, Vec<u8>>,
323
324    offline_queue: OfflineQueue,
325    backup_manager: BackupManager,
326    is_online: bool,
327}
328
329impl CloudSaveClient {
330    const MAX_RETRIES: u32 = 3;
331    const MAX_CONCURRENT_UPLOADS: usize = 2;
332
333    pub fn new(provider: CloudProvider, user_id: impl Into<String>) -> Self {
334        Self {
335            provider,
336            user_id: user_id.into(),
337            state: CloudSyncState::Idle,
338            stats: CloudSaveStats::default(),
339            metadata: CloudMetadata { quota_bytes: 100 * 1024 * 1024, ..Default::default() },
340            conflict_resolution: ConflictResolution::MergeByTimestamp,
341            remote_store: HashMap::new(),
342            local_store: HashMap::new(),
343            offline_queue: OfflineQueue::new(),
344            backup_manager: BackupManager::new(),
345            is_online: true,
346        }
347    }
348
349    pub fn set_online(&mut self, online: bool) {
350        self.is_online = online;
351        if online {
352            self.drain_offline_queue();
353        }
354    }
355
356    /// Upload a slot to the (simulated) remote store.
357    pub fn upload_slot(&mut self, slot_id: u32, data: Vec<u8>, timestamp: u64) -> Result<(), String> {
358        if !self.is_online {
359            self.offline_queue.push(PendingOp::Upload { slot_id, data, timestamp });
360            return Err("offline — operation queued".into());
361        }
362
363        self.backup_manager.backup(slot_id, data.clone(), timestamp);
364
365        let mut last_err = String::new();
366        for attempt in 0..Self::MAX_RETRIES {
367            match self.do_upload(slot_id, data.clone(), timestamp) {
368                Ok(()) => {
369                    self.stats.record_upload(data.len());
370                    self.state = CloudSyncState::Uploading(1.0);
371                    return Ok(());
372                }
373                Err(e) => {
374                    last_err = e;
375                    let _backoff_ms = 100u64 * (1 << attempt);
376                    // In real code we would sleep here; in tests we skip
377                }
378            }
379        }
380        self.state = CloudSyncState::Error(last_err.clone());
381        Err(last_err)
382    }
383
384    fn do_upload(&mut self, slot_id: u32, data: Vec<u8>, timestamp: u64) -> Result<(), String> {
385        let entry = CloudSaveEntry::new(slot_id, &self.user_id, data, timestamp);
386        self.metadata.used_bytes = self.metadata.used_bytes
387            .saturating_add(entry.size_bytes as u64);
388        self.remote_store.insert(slot_id, entry);
389        Ok(())
390    }
391
392    /// Download a slot from the (simulated) remote store.
393    pub fn download_slot(&mut self, slot_id: u32) -> Result<Vec<u8>, String> {
394        if !self.is_online {
395            self.offline_queue.push(PendingOp::Download { slot_id });
396            return Err("offline — operation queued".into());
397        }
398
399        let entry = self.remote_store.get(&slot_id)
400            .ok_or_else(|| format!("slot {slot_id} not found in remote store"))?
401            .clone();
402
403        if !entry.verify_checksum() {
404            return Err(format!("slot {slot_id} checksum mismatch on download"));
405        }
406
407        self.stats.record_download(entry.size_bytes);
408        self.state = CloudSyncState::Downloading(1.0);
409        self.local_store.insert(slot_id, entry.save_data.clone());
410        Ok(entry.save_data)
411    }
412
413    /// Detect and resolve conflicts for a slot.
414    ///
415    /// A conflict exists when local and remote checksums differ.
416    pub fn resolve_conflict(
417        &mut self,
418        slot_id: u32,
419        local_data: Vec<u8>,
420        local_timestamp: u64,
421        resolution: ConflictResolution,
422    ) -> Result<Vec<u8>, String> {
423        let remote = self.remote_store.get(&slot_id).cloned();
424        let local_entry = CloudSaveEntry::new(slot_id, &self.user_id, local_data, local_timestamp);
425
426        match remote {
427            None => {
428                // No remote — local wins by default
429                Ok(local_entry.save_data)
430            }
431            Some(remote_entry) => {
432                if local_entry.checksum == remote_entry.checksum {
433                    return Ok(local_entry.save_data); // no conflict
434                }
435                self.stats.record_conflict_resolved();
436                let winner = match resolution {
437                    ConflictResolution::LocalWins => local_entry.save_data,
438                    ConflictResolution::RemoteWins => remote_entry.save_data,
439                    ConflictResolution::MergeByTimestamp => {
440                        if local_entry.timestamp >= remote_entry.timestamp {
441                            local_entry.save_data
442                        } else {
443                            remote_entry.save_data
444                        }
445                    }
446                    ConflictResolution::MergeByVersion => {
447                        // Pick larger data as proxy for "more data = later version"
448                        if local_entry.size_bytes >= remote_entry.size_bytes {
449                            local_entry.save_data
450                        } else {
451                            remote_entry.save_data
452                        }
453                    }
454                    ConflictResolution::UserChoice => {
455                        // Default to local when no UI callback provided
456                        local_entry.save_data
457                    }
458                };
459                Ok(winner)
460            }
461        }
462    }
463
464    /// Sync all locally known slots to the remote.
465    pub fn sync_all(&mut self, timestamp: u64) -> Result<(), String> {
466        let slots: Vec<(u32, Vec<u8>)> = self.local_store
467            .iter()
468            .map(|(&id, data)| (id, data.clone()))
469            .collect();
470
471        let mut errors = Vec::new();
472        let mut in_flight = 0usize;
473
474        for (slot_id, data) in slots {
475            if in_flight >= Self::MAX_CONCURRENT_UPLOADS {
476                in_flight = 0; // simulate completing a batch
477            }
478            if let Err(e) = self.upload_slot(slot_id, data, timestamp) {
479                errors.push(e);
480            } else {
481                in_flight += 1;
482            }
483        }
484
485        if errors.is_empty() {
486            self.metadata.last_sync_time = timestamp;
487            self.state = CloudSyncState::SyncComplete;
488            Ok(())
489        } else {
490            let msg = errors.join("; ");
491            self.state = CloudSyncState::Error(msg.clone());
492            Err(msg)
493        }
494    }
495
496    /// Store a local copy without uploading.
497    pub fn set_local(&mut self, slot_id: u32, data: Vec<u8>) {
498        self.local_store.insert(slot_id, data);
499    }
500
501    pub fn offline_queue(&self) -> &OfflineQueue {
502        &self.offline_queue
503    }
504
505    pub fn backup_manager(&self) -> &BackupManager {
506        &self.backup_manager
507    }
508
509    pub fn backup_manager_mut(&mut self) -> &mut BackupManager {
510        &mut self.backup_manager
511    }
512
513    fn drain_offline_queue(&mut self) {
514        let ops = self.offline_queue.drain();
515        for op in ops {
516            match op {
517                PendingOp::Upload { slot_id, data, timestamp } => {
518                    let _ = self.do_upload(slot_id, data, timestamp);
519                }
520                PendingOp::Download { slot_id } => {
521                    // Re-queue if remote has it; ignore errors during drain
522                    let _ = self.remote_store.get(&slot_id).cloned();
523                }
524            }
525        }
526    }
527}
528
529// ─────────────────────────────────────────────────────────────────────────────
530//  Tests
531// ─────────────────────────────────────────────────────────────────────────────
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    fn make_client() -> CloudSaveClient {
538        CloudSaveClient::new(CloudProvider::SteamCloud, "user_123")
539    }
540
541    #[test]
542    fn test_upload_and_download_roundtrip() {
543        let mut client = make_client();
544        let data = b"save data content".to_vec();
545        client.upload_slot(0, data.clone(), 1000).unwrap();
546        let downloaded = client.download_slot(0).unwrap();
547        assert_eq!(downloaded, data);
548    }
549
550    #[test]
551    fn test_upload_creates_backup() {
552        let mut client = make_client();
553        let data = b"backup test".to_vec();
554        client.upload_slot(1, data.clone(), 2000).unwrap();
555        assert_eq!(client.backup_manager().backup_count(1), 1);
556    }
557
558    #[test]
559    fn test_offline_queue_enqueue() {
560        let mut client = make_client();
561        client.set_online(false);
562        let result = client.upload_slot(0, b"data".to_vec(), 100);
563        assert!(result.is_err());
564        assert_eq!(client.offline_queue().len(), 1);
565    }
566
567    #[test]
568    fn test_offline_queue_max_100() {
569        let mut queue = OfflineQueue::new();
570        for i in 0..105u64 {
571            queue.push(PendingOp::Upload { slot_id: i as u32, data: vec![], timestamp: i });
572        }
573        assert_eq!(queue.len(), 100);
574    }
575
576    #[test]
577    fn test_offline_queue_drains_on_reconnect() {
578        let mut client = make_client();
579        client.set_online(false);
580        let _ = client.upload_slot(5, b"queued".to_vec(), 500);
581        assert_eq!(client.offline_queue().len(), 1);
582        client.set_online(true);
583        assert_eq!(client.offline_queue().len(), 0);
584    }
585
586    #[test]
587    fn test_conflict_resolution_local_wins() {
588        let mut client = make_client();
589        let remote_data = b"remote version".to_vec();
590        client.upload_slot(0, remote_data.clone(), 100).unwrap();
591        let local_data = b"local version".to_vec();
592        let result = client.resolve_conflict(0, local_data.clone(), 200, ConflictResolution::LocalWins).unwrap();
593        assert_eq!(result, local_data);
594    }
595
596    #[test]
597    fn test_conflict_resolution_remote_wins() {
598        let mut client = make_client();
599        let remote_data = b"remote version".to_vec();
600        client.upload_slot(0, remote_data.clone(), 100).unwrap();
601        let local_data = b"local version".to_vec();
602        let result = client.resolve_conflict(0, local_data, 200, ConflictResolution::RemoteWins).unwrap();
603        assert_eq!(result, remote_data);
604    }
605
606    #[test]
607    fn test_conflict_resolution_merge_by_timestamp() {
608        let mut client = make_client();
609        client.upload_slot(0, b"old remote".to_vec(), 100).unwrap();
610        let local_data = b"newer local".to_vec();
611        let result = client.resolve_conflict(0, local_data.clone(), 999, ConflictResolution::MergeByTimestamp).unwrap();
612        assert_eq!(result, local_data);
613    }
614
615    #[test]
616    fn test_encryption_roundtrip() {
617        let data = b"sensitive save data 123".to_vec();
618        let encrypted = SaveEncryption::encrypt(&data, "user_abc");
619        assert_ne!(encrypted, data);
620        let decrypted = SaveEncryption::decrypt(&encrypted, "user_abc").unwrap();
621        assert_eq!(decrypted, data);
622    }
623
624    #[test]
625    fn test_encryption_different_users_differ() {
626        let data = b"hello world".to_vec();
627        let enc1 = SaveEncryption::encrypt(&data, "user_a");
628        let enc2 = SaveEncryption::encrypt(&data, "user_b");
629        assert_ne!(enc1, enc2);
630    }
631
632    #[test]
633    fn test_backup_manager_max_5() {
634        let mut bm = BackupManager::new();
635        for i in 0..7u64 {
636            bm.backup(0, vec![i as u8], i);
637        }
638        assert_eq!(bm.backup_count(0), 5);
639    }
640
641    #[test]
642    fn test_backup_manager_restore() {
643        let mut bm = BackupManager::new();
644        bm.backup(0, b"first".to_vec(), 1);
645        bm.backup(0, b"second".to_vec(), 2);
646        let restored = bm.restore_backup(0, 0).unwrap();
647        assert_eq!(restored, b"first");
648    }
649
650    #[test]
651    fn test_backup_manager_list() {
652        let mut bm = BackupManager::new();
653        bm.backup(0, b"v1".to_vec(), 100);
654        bm.backup(0, b"v2".to_vec(), 200);
655        let list = bm.list_backups(0);
656        assert_eq!(list.len(), 2);
657        assert_eq!(list[0].backup_idx, 0);
658        assert_eq!(list[1].backup_idx, 1);
659    }
660
661    #[test]
662    fn test_cloud_metadata_usage() {
663        let meta = CloudMetadata {
664            last_sync_time: 0,
665            total_slots: 10,
666            used_bytes: 50,
667            quota_bytes: 100,
668        };
669        assert!((meta.usage_fraction() - 0.5).abs() < 1e-6);
670        assert!(meta.has_space(40));
671        assert!(!meta.has_space(60));
672    }
673
674    #[test]
675    fn test_sync_all() {
676        let mut client = make_client();
677        client.set_local(0, b"slot0".to_vec());
678        client.set_local(1, b"slot1".to_vec());
679        client.sync_all(9999).unwrap();
680        assert!(matches!(client.state, CloudSyncState::SyncComplete));
681    }
682}