saorsa_core/upgrade/
rollback.rs

1// Copyright (c) 2025 Saorsa Labs Limited
2
3// This file is part of the Saorsa P2P network.
4
5// Licensed under the AGPL-3.0 license:
6// <https://www.gnu.org/licenses/agpl-3.0.html>
7
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU Affero General Public License for more details.
12
13// You should have received a copy of the GNU Affero General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16// Copyright 2024 P2P Foundation
17// SPDX-License-Identifier: AGPL-3.0-or-later
18
19//! Rollback and backup management for updates.
20
21use serde::{Deserialize, Serialize};
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24
25use super::error::UpgradeError;
26use super::manifest::Platform;
27
28/// Metadata about a backup.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct BackupMetadata {
31    /// Version that was backed up.
32    pub version: String,
33
34    /// When the backup was created (Unix timestamp).
35    pub created_at: u64,
36
37    /// Path to the backup binary (relative to backup dir).
38    pub backup_filename: String,
39
40    /// Original path of the binary.
41    pub original_path: String,
42
43    /// Platform identifier.
44    pub platform: String,
45
46    /// SHA-256 checksum of the backup.
47    pub checksum: String,
48
49    /// Size in bytes.
50    pub size: u64,
51}
52
53impl BackupMetadata {
54    /// Get the age of this backup.
55    #[must_use]
56    pub fn age(&self) -> Duration {
57        let now = std::time::SystemTime::now()
58            .duration_since(std::time::UNIX_EPOCH)
59            .map(|d| d.as_secs())
60            .unwrap_or(0);
61
62        Duration::from_secs(now.saturating_sub(self.created_at))
63    }
64}
65
66/// Manager for backup and rollback operations.
67pub struct RollbackManager {
68    /// Directory for storing backups.
69    backup_dir: PathBuf,
70
71    /// Maximum age for backups before cleanup.
72    max_backup_age: Duration,
73
74    /// Maximum number of backups to retain.
75    max_backups: usize,
76}
77
78impl RollbackManager {
79    /// Create a new rollback manager.
80    #[must_use]
81    pub fn new(backup_dir: PathBuf) -> Self {
82        Self {
83            backup_dir,
84            max_backup_age: Duration::from_secs(30 * 24 * 3600), // 30 days
85            max_backups: 5,
86        }
87    }
88
89    /// Set maximum backup age.
90    #[must_use]
91    pub fn with_max_age(mut self, age: Duration) -> Self {
92        self.max_backup_age = age;
93        self
94    }
95
96    /// Set maximum number of backups.
97    #[must_use]
98    pub fn with_max_backups(mut self, count: usize) -> Self {
99        self.max_backups = count;
100        self
101    }
102
103    /// Get the backup directory.
104    #[must_use]
105    pub fn backup_dir(&self) -> &Path {
106        &self.backup_dir
107    }
108
109    /// Ensure the backup directory exists.
110    pub async fn ensure_backup_dir(&self) -> Result<(), UpgradeError> {
111        tokio::fs::create_dir_all(&self.backup_dir)
112            .await
113            .map_err(|e| UpgradeError::io(format!("failed to create backup directory: {}", e)))
114    }
115
116    /// Get the metadata file path.
117    fn metadata_path(&self) -> PathBuf {
118        self.backup_dir.join("backups.json")
119    }
120
121    /// Load all backup metadata.
122    pub async fn load_metadata(&self) -> Result<Vec<BackupMetadata>, UpgradeError> {
123        let path = self.metadata_path();
124        if !path.exists() {
125            return Ok(Vec::new());
126        }
127
128        let json = tokio::fs::read_to_string(&path)
129            .await
130            .map_err(|e| UpgradeError::io(format!("failed to read backup metadata: {}", e)))?;
131
132        let backups: Vec<BackupMetadata> = serde_json::from_str(&json)
133            .map_err(|e| UpgradeError::io(format!("failed to parse backup metadata: {}", e)))?;
134
135        Ok(backups)
136    }
137
138    /// Save backup metadata.
139    async fn save_metadata(&self, backups: &[BackupMetadata]) -> Result<(), UpgradeError> {
140        let json = serde_json::to_string_pretty(backups)
141            .map_err(|e| UpgradeError::io(format!("failed to serialize backup metadata: {}", e)))?;
142
143        tokio::fs::write(self.metadata_path(), json)
144            .await
145            .map_err(|e| UpgradeError::io(format!("failed to write backup metadata: {}", e)))
146    }
147
148    /// Create a backup of a binary.
149    pub async fn create_backup(
150        &self,
151        binary_path: &Path,
152        version: &str,
153        platform: Platform,
154    ) -> Result<BackupMetadata, UpgradeError> {
155        self.ensure_backup_dir().await?;
156
157        // Read the binary
158        let contents = tokio::fs::read(binary_path)
159            .await
160            .map_err(|e| UpgradeError::io(format!("failed to read binary for backup: {}", e)))?;
161
162        // Calculate checksum
163        let checksum = super::verifier::SignatureVerifier::calculate_checksum(&contents);
164
165        // Generate backup filename
166        let extension = if platform.is_windows() { ".exe" } else { "" };
167        let timestamp = std::time::SystemTime::now()
168            .duration_since(std::time::UNIX_EPOCH)
169            .map(|d| d.as_secs())
170            .unwrap_or(0);
171
172        let backup_filename = format!(
173            "saorsa-{}-{}-{}{}.bak",
174            version,
175            platform.as_str(),
176            timestamp,
177            extension
178        );
179
180        let backup_path = self.backup_dir.join(&backup_filename);
181
182        // Write the backup
183        tokio::fs::write(&backup_path, &contents)
184            .await
185            .map_err(|e| UpgradeError::io(format!("failed to write backup: {}", e)))?;
186
187        let metadata = BackupMetadata {
188            version: version.to_string(),
189            created_at: timestamp,
190            backup_filename,
191            original_path: binary_path.to_string_lossy().to_string(),
192            platform: platform.as_str().to_string(),
193            checksum,
194            size: contents.len() as u64,
195        };
196
197        // Update metadata file
198        let mut backups = self.load_metadata().await.unwrap_or_default();
199        backups.push(metadata.clone());
200        self.save_metadata(&backups).await?;
201
202        // Cleanup old backups
203        self.cleanup_old_backups().await?;
204
205        Ok(metadata)
206    }
207
208    /// Get the most recent backup.
209    pub async fn get_latest_backup(&self) -> Result<Option<BackupMetadata>, UpgradeError> {
210        let backups = self.load_metadata().await?;
211        Ok(backups.into_iter().max_by_key(|b| b.created_at))
212    }
213
214    /// Get backup for a specific version.
215    pub async fn get_backup_for_version(
216        &self,
217        version: &str,
218    ) -> Result<Option<BackupMetadata>, UpgradeError> {
219        let backups = self.load_metadata().await?;
220        Ok(backups.into_iter().find(|b| b.version == version))
221    }
222
223    /// Check if rollback is available.
224    pub async fn can_rollback(&self) -> bool {
225        if let Ok(Some(backup)) = self.get_latest_backup().await {
226            let backup_path = self.backup_dir.join(&backup.backup_filename);
227            backup_path.exists()
228        } else {
229            false
230        }
231    }
232
233    /// Perform a rollback to the previous version.
234    pub async fn rollback(&self) -> Result<BackupMetadata, UpgradeError> {
235        let backup = self
236            .get_latest_backup()
237            .await?
238            .ok_or_else(|| UpgradeError::NoRollback("no backup available".into()))?;
239
240        let backup_path = self.backup_dir.join(&backup.backup_filename);
241
242        if !backup_path.exists() {
243            return Err(UpgradeError::NoRollback("backup file not found".into()));
244        }
245
246        // Verify backup integrity
247        let contents = tokio::fs::read(&backup_path)
248            .await
249            .map_err(|e| UpgradeError::io(format!("failed to read backup: {}", e)))?;
250
251        let actual_checksum = super::verifier::SignatureVerifier::calculate_checksum(&contents);
252
253        if actual_checksum != backup.checksum {
254            return Err(UpgradeError::Rollback(
255                format!(
256                    "backup corrupted: checksum mismatch (expected {}, got {})",
257                    backup.checksum, actual_checksum
258                )
259                .into(),
260            ));
261        }
262
263        // Restore to original path
264        let original_path = PathBuf::from(&backup.original_path);
265
266        // Create parent directory if needed
267        if let Some(parent) = original_path.parent() {
268            tokio::fs::create_dir_all(parent)
269                .await
270                .map_err(|e| UpgradeError::io(format!("failed to create directory: {}", e)))?;
271        }
272
273        // Write the restored binary
274        tokio::fs::write(&original_path, &contents)
275            .await
276            .map_err(|e| {
277                UpgradeError::Rollback(format!("failed to restore binary: {}", e).into())
278            })?;
279
280        // Set executable permissions on Unix
281        #[cfg(unix)]
282        {
283            use std::os::unix::fs::PermissionsExt;
284            let perms = std::fs::Permissions::from_mode(0o755);
285            tokio::fs::set_permissions(&original_path, perms)
286                .await
287                .map_err(|e| UpgradeError::io(format!("failed to set permissions: {}", e)))?;
288        }
289
290        Ok(backup)
291    }
292
293    /// Rollback to a specific version.
294    pub async fn rollback_to_version(&self, version: &str) -> Result<BackupMetadata, UpgradeError> {
295        let backup = self.get_backup_for_version(version).await?.ok_or_else(|| {
296            UpgradeError::NoRollback(format!("no backup for version {}", version).into())
297        })?;
298
299        let backup_path = self.backup_dir.join(&backup.backup_filename);
300
301        if !backup_path.exists() {
302            return Err(UpgradeError::NoRollback("backup file not found".into()));
303        }
304
305        // Read and verify
306        let contents = tokio::fs::read(&backup_path)
307            .await
308            .map_err(|e| UpgradeError::io(format!("failed to read backup: {}", e)))?;
309
310        let actual_checksum = super::verifier::SignatureVerifier::calculate_checksum(&contents);
311
312        if actual_checksum != backup.checksum {
313            return Err(UpgradeError::Rollback("backup corrupted".into()));
314        }
315
316        // Restore
317        let original_path = PathBuf::from(&backup.original_path);
318        tokio::fs::write(&original_path, &contents)
319            .await
320            .map_err(|e| {
321                UpgradeError::Rollback(format!("failed to restore binary: {}", e).into())
322            })?;
323
324        // Set executable permissions on Unix
325        #[cfg(unix)]
326        {
327            use std::os::unix::fs::PermissionsExt;
328            let perms = std::fs::Permissions::from_mode(0o755);
329            tokio::fs::set_permissions(&original_path, perms)
330                .await
331                .map_err(|e| UpgradeError::io(format!("failed to set permissions: {}", e)))?;
332        }
333
334        Ok(backup)
335    }
336
337    /// List all available backups.
338    pub async fn list_backups(&self) -> Result<Vec<BackupMetadata>, UpgradeError> {
339        let mut backups = self.load_metadata().await?;
340        backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
341        Ok(backups)
342    }
343
344    /// Clean up old backups based on age and count limits.
345    pub async fn cleanup_old_backups(&self) -> Result<usize, UpgradeError> {
346        let mut backups = self.load_metadata().await?;
347        let original_count = backups.len();
348
349        // Sort by creation time (newest first)
350        backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
351
352        // Filter out old backups
353        let mut to_remove = Vec::new();
354        let mut to_keep = Vec::new();
355
356        for (i, backup) in backups.into_iter().enumerate() {
357            let too_old = backup.age() > self.max_backup_age;
358            let exceeds_limit = i >= self.max_backups;
359
360            if too_old || exceeds_limit {
361                to_remove.push(backup);
362            } else {
363                to_keep.push(backup);
364            }
365        }
366
367        // Delete removed backup files
368        for backup in &to_remove {
369            let path = self.backup_dir.join(&backup.backup_filename);
370            let _ = tokio::fs::remove_file(&path).await;
371        }
372
373        // Save updated metadata
374        self.save_metadata(&to_keep).await?;
375
376        Ok(original_count - to_keep.len())
377    }
378
379    /// Delete a specific backup.
380    pub async fn delete_backup(&self, version: &str) -> Result<bool, UpgradeError> {
381        let mut backups = self.load_metadata().await?;
382
383        if let Some(pos) = backups.iter().position(|b| b.version == version) {
384            let backup = backups.remove(pos);
385
386            // Delete the file
387            let path = self.backup_dir.join(&backup.backup_filename);
388            let _ = tokio::fs::remove_file(&path).await;
389
390            // Save updated metadata
391            self.save_metadata(&backups).await?;
392            Ok(true)
393        } else {
394            Ok(false)
395        }
396    }
397
398    /// Clean up all backups.
399    pub async fn cleanup_all(&self) -> Result<(), UpgradeError> {
400        if self.backup_dir.exists() {
401            tokio::fs::remove_dir_all(&self.backup_dir)
402                .await
403                .map_err(|e| UpgradeError::io(format!("failed to cleanup backups: {}", e)))?;
404        }
405        Ok(())
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use tempfile::TempDir;
413
414    #[test]
415    fn test_backup_metadata_age() {
416        let metadata = BackupMetadata {
417            version: "1.0.0".to_string(),
418            created_at: std::time::SystemTime::now()
419                .duration_since(std::time::UNIX_EPOCH)
420                .map(|d| d.as_secs() - 3600) // 1 hour ago
421                .unwrap_or(0),
422            backup_filename: "test.bak".to_string(),
423            original_path: "/usr/bin/saorsa".to_string(),
424            platform: "linux-x64".to_string(),
425            checksum: "abc123".to_string(),
426            size: 1000,
427        };
428
429        let age = metadata.age();
430        // Should be approximately 1 hour (3600 seconds)
431        assert!(age.as_secs() >= 3590 && age.as_secs() <= 3610);
432    }
433
434    #[test]
435    fn test_rollback_manager_creation() {
436        let manager = RollbackManager::new(PathBuf::from("/backup"))
437            .with_max_age(Duration::from_secs(7 * 24 * 3600))
438            .with_max_backups(3);
439
440        assert_eq!(manager.max_backup_age, Duration::from_secs(7 * 24 * 3600));
441        assert_eq!(manager.max_backups, 3);
442    }
443
444    #[tokio::test]
445    async fn test_backup_and_rollback() {
446        let temp_dir = TempDir::new().unwrap();
447        let backup_dir = temp_dir.path().join("backups");
448        let manager = RollbackManager::new(backup_dir);
449
450        // Create a test binary
451        let binary_path = temp_dir.path().join("test-binary");
452        tokio::fs::write(&binary_path, b"binary content")
453            .await
454            .unwrap();
455
456        // Create backup
457        let backup = manager
458            .create_backup(&binary_path, "1.0.0", Platform::LinuxX64)
459            .await
460            .unwrap();
461
462        assert_eq!(backup.version, "1.0.0");
463        assert_eq!(backup.size, 14);
464
465        // Should be able to rollback now
466        assert!(manager.can_rollback().await);
467
468        // List backups
469        let backups = manager.list_backups().await.unwrap();
470        assert_eq!(backups.len(), 1);
471
472        // Delete the original binary
473        tokio::fs::remove_file(&binary_path).await.unwrap();
474
475        // Rollback
476        let restored = manager.rollback().await.unwrap();
477        assert_eq!(restored.version, "1.0.0");
478
479        // Binary should exist again
480        assert!(binary_path.exists());
481
482        let contents = tokio::fs::read(&binary_path).await.unwrap();
483        assert_eq!(contents, b"binary content");
484    }
485
486    #[tokio::test]
487    async fn test_multiple_backups() {
488        let temp_dir = TempDir::new().unwrap();
489        let backup_dir = temp_dir.path().join("backups");
490        let manager = RollbackManager::new(backup_dir).with_max_backups(3);
491
492        let binary_path = temp_dir.path().join("test-binary");
493
494        // Create multiple backups
495        for i in 1..=5 {
496            let version = format!("1.0.{}", i);
497            let content = format!("version {}", i);
498            tokio::fs::write(&binary_path, content.as_bytes())
499                .await
500                .unwrap();
501
502            manager
503                .create_backup(&binary_path, &version, Platform::LinuxX64)
504                .await
505                .unwrap();
506        }
507
508        // Should only keep 3 most recent
509        let backups = manager.list_backups().await.unwrap();
510        assert!(backups.len() <= 3);
511    }
512
513    #[tokio::test]
514    async fn test_delete_backup() {
515        let temp_dir = TempDir::new().unwrap();
516        let backup_dir = temp_dir.path().join("backups");
517        let manager = RollbackManager::new(backup_dir);
518
519        let binary_path = temp_dir.path().join("test-binary");
520        tokio::fs::write(&binary_path, b"test").await.unwrap();
521
522        manager
523            .create_backup(&binary_path, "1.0.0", Platform::LinuxX64)
524            .await
525            .unwrap();
526
527        assert!(manager.can_rollback().await);
528
529        let deleted = manager.delete_backup("1.0.0").await.unwrap();
530        assert!(deleted);
531
532        assert!(!manager.can_rollback().await);
533    }
534}