1use serde::{Deserialize, Serialize};
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24
25use super::error::UpgradeError;
26use super::manifest::Platform;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct BackupMetadata {
31 pub version: String,
33
34 pub created_at: u64,
36
37 pub backup_filename: String,
39
40 pub original_path: String,
42
43 pub platform: String,
45
46 pub checksum: String,
48
49 pub size: u64,
51}
52
53impl BackupMetadata {
54 #[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
66pub struct RollbackManager {
68 backup_dir: PathBuf,
70
71 max_backup_age: Duration,
73
74 max_backups: usize,
76}
77
78impl RollbackManager {
79 #[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), max_backups: 5,
86 }
87 }
88
89 #[must_use]
91 pub fn with_max_age(mut self, age: Duration) -> Self {
92 self.max_backup_age = age;
93 self
94 }
95
96 #[must_use]
98 pub fn with_max_backups(mut self, count: usize) -> Self {
99 self.max_backups = count;
100 self
101 }
102
103 #[must_use]
105 pub fn backup_dir(&self) -> &Path {
106 &self.backup_dir
107 }
108
109 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 fn metadata_path(&self) -> PathBuf {
118 self.backup_dir.join("backups.json")
119 }
120
121 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 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 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 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 let checksum = super::verifier::SignatureVerifier::calculate_checksum(&contents);
164
165 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 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 let mut backups = self.load_metadata().await.unwrap_or_default();
199 backups.push(metadata.clone());
200 self.save_metadata(&backups).await?;
201
202 self.cleanup_old_backups().await?;
204
205 Ok(metadata)
206 }
207
208 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 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 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 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 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 let original_path = PathBuf::from(&backup.original_path);
265
266 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 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 #[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 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 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 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 #[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 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 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 backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
351
352 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 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 self.save_metadata(&to_keep).await?;
375
376 Ok(original_count - to_keep.len())
377 }
378
379 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 let path = self.backup_dir.join(&backup.backup_filename);
388 let _ = tokio::fs::remove_file(&path).await;
389
390 self.save_metadata(&backups).await?;
392 Ok(true)
393 } else {
394 Ok(false)
395 }
396 }
397
398 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) .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 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 let binary_path = temp_dir.path().join("test-binary");
452 tokio::fs::write(&binary_path, b"binary content")
453 .await
454 .unwrap();
455
456 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 assert!(manager.can_rollback().await);
467
468 let backups = manager.list_backups().await.unwrap();
470 assert_eq!(backups.len(), 1);
471
472 tokio::fs::remove_file(&binary_path).await.unwrap();
474
475 let restored = manager.rollback().await.unwrap();
477 assert_eq!(restored.version, "1.0.0");
478
479 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 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 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}