agpm_cli/upgrade/
backup.rs

1use anyhow::{Context, Result, bail};
2use std::path::{Path, PathBuf};
3use tokio::fs;
4use tracing::{debug, info, warn};
5
6/// Manages backup and restoration of AGPM binaries during upgrades.
7///
8/// `BackupManager` provides comprehensive backup functionality to protect against
9/// failed upgrades and enable rollback capabilities. It creates backups of the
10/// current binary before upgrades and can restore them if needed.
11///
12/// # Safety Features
13///
14/// - **Automatic Backup Creation**: Creates backups before any binary modification
15/// - **Permission Preservation**: Maintains file permissions and metadata on Unix systems
16/// - **Atomic Operations**: Uses file copy operations for reliability
17/// - **Retry Logic**: Handles Windows file locking issues with automatic retries
18/// - **Rollback Support**: Enables quick restoration from backups
19///
20/// # Backup Strategy
21///
22/// The backup manager creates a copy of the original binary with a `.backup` suffix
23/// in the same directory. This approach:
24/// - Keeps backups close to the original for easy access
25/// - Preserves the same file system and permissions context
26/// - Allows for quick restoration without complex path management
27/// - Works consistently across different installation methods
28///
29/// # Cross-Platform Considerations
30///
31/// ## Unix Systems (Linux, macOS)
32/// - Preserves executable permissions and ownership
33/// - Uses standard file copy operations
34/// - Handles symbolic links appropriately
35///
36/// ## Windows
37/// - Implements retry logic for file locking issues
38/// - Handles executable files that might be in use
39/// - Works with Windows permission models
40///
41/// # Examples
42///
43/// ## Basic Backup and Restore
44/// ```rust,no_run
45/// use agpm_cli::upgrade::backup::BackupManager;
46/// use std::path::PathBuf;
47///
48/// # async fn example() -> anyhow::Result<()> {
49/// let exe_path = PathBuf::from("/usr/local/bin/agpm");
50/// let backup_manager = BackupManager::new(exe_path);
51///
52/// // Create backup before upgrade
53/// backup_manager.create_backup().await?;
54///
55/// // ... perform upgrade ...
56///
57/// // Restore if upgrade failed
58/// let upgrade_failed = false; // Set based on upgrade result
59/// if upgrade_failed {
60///     backup_manager.restore_backup().await?;
61/// } else {
62///     backup_manager.cleanup_backup().await?;
63/// }
64/// # Ok(())
65/// # }
66/// ```
67///
68/// ## Check for Existing Backup
69/// ```rust,no_run
70/// use agpm_cli::upgrade::backup::BackupManager;
71/// use std::path::PathBuf;
72///
73/// let backup_manager = BackupManager::new(PathBuf::from("agpm"));
74///
75/// if backup_manager.backup_exists() {
76///     println!("Backup found at: {}", backup_manager.backup_path().display());
77/// }
78/// ```
79///
80/// # Error Handling
81///
82/// All operations return `Result<T, anyhow::Error>` with detailed error context:
83/// - Permission errors when unable to read/write files
84/// - File system errors during copy operations
85/// - Platform-specific issues (Windows file locking, Unix permissions)
86///
87/// # Implementation Details
88///
89/// - Uses `tokio::fs` for async file operations
90/// - Implements platform-specific permission handling
91/// - Provides detailed logging for debugging and monitoring
92/// - Handles edge cases like missing files and permission issues
93pub struct BackupManager {
94    /// Path to the original binary file.
95    original_path: PathBuf,
96    /// Path where the backup will be stored.
97    backup_path: PathBuf,
98}
99
100impl BackupManager {
101    /// Create a new `BackupManager` for the specified executable.
102    ///
103    /// Automatically determines the backup file path by appending `.backup`
104    /// to the original executable name in the same directory.
105    ///
106    /// # Arguments
107    ///
108    /// * `executable_path` - Full path to the executable binary to manage
109    ///
110    /// # Examples
111    ///
112    /// ```rust,no_run
113    /// use agpm_cli::upgrade::backup::BackupManager;
114    /// use std::path::PathBuf;
115    ///
116    /// // Unix-style path
117    /// let manager = BackupManager::new(PathBuf::from("/usr/local/bin/agpm"));
118    /// // Backup will be at /usr/local/bin/agpm.backup
119    ///
120    /// // Windows-style path
121    /// let manager = BackupManager::new(PathBuf::from(r"C:\Program Files\agpm\agpm.exe"));
122    /// // Backup will be at C:\Program Files\agpm\agpm.exe.backup
123    /// ```
124    pub fn new(executable_path: PathBuf) -> Self {
125        let mut backup_path = executable_path.clone();
126        backup_path.set_file_name(format!(
127            "{}.backup",
128            executable_path.file_name().unwrap_or_default().to_string_lossy()
129        ));
130
131        Self {
132            original_path: executable_path,
133            backup_path,
134        }
135    }
136
137    /// Create a backup of the original binary.
138    ///
139    /// Copies the current binary to the backup location, preserving permissions
140    /// and metadata. If a backup already exists, it will be replaced.
141    ///
142    /// # Process
143    ///
144    /// 1. Validate that the original file exists
145    /// 2. Remove any existing backup file
146    /// 3. Copy the original file to the backup location
147    /// 4. Preserve file permissions on Unix systems
148    ///
149    /// # Returns
150    ///
151    /// - `Ok(())` - Backup created successfully
152    /// - `Err(error)` - Backup creation failed
153    ///
154    /// # Errors
155    ///
156    /// This method can fail if:
157    /// - The original file doesn't exist or is not readable
158    /// - Insufficient permissions to create the backup file
159    /// - File system errors during the copy operation
160    /// - Unable to set permissions on the backup file (Unix)
161    ///
162    /// # Examples
163    ///
164    /// ```rust,no_run
165    /// use agpm_cli::upgrade::backup::BackupManager;
166    /// use std::path::PathBuf;
167    ///
168    /// # async fn example() -> anyhow::Result<()> {
169    /// let manager = BackupManager::new(PathBuf::from("./agpm"));
170    ///
171    /// match manager.create_backup().await {
172    ///     Ok(()) => println!("Backup created successfully"),
173    ///     Err(e) => eprintln!("Failed to create backup: {}", e),
174    /// }
175    /// # Ok(())
176    /// # }
177    /// ```
178    pub async fn create_backup(&self) -> Result<()> {
179        if !self.original_path.exists() {
180            bail!("Original file does not exist: {:?}", self.original_path);
181        }
182
183        // Remove old backup if it exists
184        if self.backup_path.exists() {
185            debug!("Removing old backup at {:?}", self.backup_path);
186            fs::remove_file(&self.backup_path).await.context("Failed to remove old backup")?;
187        }
188
189        // Copy current binary to backup location
190        info!("Creating backup at {:?}", self.backup_path);
191        fs::copy(&self.original_path, &self.backup_path)
192            .await
193            .context("Failed to create backup")?;
194
195        // Preserve permissions on Unix
196        #[cfg(unix)]
197        {
198            let metadata = fs::metadata(&self.original_path)
199                .await
200                .context("Failed to read original file metadata")?;
201            let permissions = metadata.permissions();
202            fs::set_permissions(&self.backup_path, permissions)
203                .await
204                .context("Failed to set backup permissions")?;
205        }
206
207        info!("Backup created successfully");
208        Ok(())
209    }
210
211    /// Restore the original binary from backup.
212    ///
213    /// Replaces the current binary with the backup copy, effectively rolling
214    /// back to the previous version. This operation includes retry logic for
215    /// Windows systems where the binary might be locked.
216    ///
217    /// # Process
218    ///
219    /// 1. Validate that a backup file exists
220    /// 2. Remove the current (potentially corrupted) binary
221    /// 3. Copy the backup file back to the original location
222    /// 4. Restore file permissions on Unix systems
223    /// 5. Retry up to 3 times on Windows for file locking issues
224    ///
225    /// # Returns
226    ///
227    /// - `Ok(())` - Backup restored successfully
228    /// - `Err(error)` - Restoration failed after all retries
229    ///
230    /// # Errors
231    ///
232    /// This method can fail if:
233    /// - No backup file exists at the expected location
234    /// - Insufficient permissions to replace the original file
235    /// - File locking issues prevent replacement (Windows)
236    /// - File system errors during the copy operation
237    /// - Unable to restore permissions (Unix)
238    ///
239    /// # Platform Behavior
240    ///
241    /// ## Windows
242    /// - Implements retry logic with 1-second delays
243    /// - Handles file locking from running processes
244    /// - Attempts up to 3 times before giving up
245    ///
246    /// ## Unix
247    /// - Preserves executable permissions and ownership
248    /// - Single attempt (usually succeeds immediately)
249    ///
250    /// # Examples
251    ///
252    /// ```rust,no_run
253    /// use agpm_cli::upgrade::backup::BackupManager;
254    /// use std::path::PathBuf;
255    ///
256    /// # async fn example() -> anyhow::Result<()> {
257    /// let manager = BackupManager::new(PathBuf::from("./agpm"));
258    ///
259    /// if manager.backup_exists() {
260    ///     match manager.restore_backup().await {
261    ///         Ok(()) => println!("Successfully restored from backup"),
262    ///         Err(e) => eprintln!("Failed to restore backup: {}", e),
263    ///     }
264    /// } else {
265    ///     eprintln!("No backup found to restore");
266    /// }
267    /// # Ok(())
268    /// # }
269    /// ```
270    pub async fn restore_backup(&self) -> Result<()> {
271        if !self.backup_path.exists() {
272            bail!("No backup found at {:?}", self.backup_path);
273        }
274
275        warn!("Restoring from backup at {:?}", self.backup_path);
276
277        // On Windows, we might need to retry if the file is in use
278        let mut attempts = 0;
279        const MAX_ATTEMPTS: u32 = 3;
280
281        while attempts < MAX_ATTEMPTS {
282            match self.attempt_restore().await {
283                Ok(()) => {
284                    info!("Successfully restored from backup");
285                    return Ok(());
286                }
287                Err(e) if attempts < MAX_ATTEMPTS - 1 => {
288                    warn!("Restore attempt {} failed: {}. Retrying...", attempts + 1, e);
289                    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
290                    attempts += 1;
291                }
292                Err(e) => return Err(e),
293            }
294        }
295
296        bail!("Failed to restore backup after {MAX_ATTEMPTS} attempts")
297    }
298
299    /// Attempt a single restoration operation.
300    ///
301    /// This is an internal method used by [`restore_backup()`](Self::restore_backup)
302    /// to handle the actual file operations. It's separated to enable retry logic
303    /// for Windows file locking issues.
304    ///
305    /// # Returns
306    ///
307    /// - `Ok(())` - Single restoration attempt succeeded
308    /// - `Err(error)` - Restoration attempt failed
309    ///
310    /// # Process
311    ///
312    /// 1. Remove the current binary file if it exists
313    /// 2. Copy the backup file to the original location
314    /// 3. Restore file permissions on Unix systems
315    async fn attempt_restore(&self) -> Result<()> {
316        // Remove the potentially corrupted binary
317        if self.original_path.exists() {
318            fs::remove_file(&self.original_path)
319                .await
320                .context("Failed to remove corrupted binary")?;
321        }
322
323        // Copy backup back to original location
324        fs::copy(&self.backup_path, &self.original_path)
325            .await
326            .context("Failed to restore backup")?;
327
328        // Restore permissions on Unix
329        #[cfg(unix)]
330        {
331            let metadata =
332                fs::metadata(&self.backup_path).await.context("Failed to read backup metadata")?;
333            let permissions = metadata.permissions();
334            fs::set_permissions(&self.original_path, permissions)
335                .await
336                .context("Failed to restore permissions")?;
337        }
338
339        Ok(())
340    }
341
342    /// Remove the backup file after a successful upgrade.
343    ///
344    /// Cleans up the backup file once it's no longer needed, typically after
345    /// a successful upgrade has been completed and verified.
346    ///
347    /// # Returns
348    ///
349    /// - `Ok(())` - Backup cleaned up successfully or no backup existed
350    /// - `Err(error)` - Failed to remove the backup file
351    ///
352    /// # Errors
353    ///
354    /// This method can fail if:
355    /// - Insufficient permissions to delete the backup file
356    /// - File system errors during deletion
357    /// - File is locked or in use (rare on most systems)
358    ///
359    /// # Examples
360    ///
361    /// ```rust,no_run
362    /// use agpm_cli::upgrade::backup::BackupManager;
363    /// use std::path::PathBuf;
364    ///
365    /// # async fn example() -> anyhow::Result<()> {
366    /// let manager = BackupManager::new(PathBuf::from("./agpm"));
367    ///
368    /// // After successful upgrade
369    /// manager.cleanup_backup().await?;
370    /// println!("Backup cleaned up");
371    /// # Ok(())
372    /// # }
373    /// ```
374    ///
375    /// # Note
376    ///
377    /// This method silently succeeds if no backup file exists, making it safe
378    /// to call unconditionally after upgrades.
379    pub async fn cleanup_backup(&self) -> Result<()> {
380        if self.backup_path.exists() {
381            debug!("Cleaning up backup at {:?}", self.backup_path);
382            fs::remove_file(&self.backup_path).await.context("Failed to remove backup")?;
383        }
384        Ok(())
385    }
386
387    /// Check if a backup file currently exists.
388    ///
389    /// This is a synchronous check that verifies whether a backup file is
390    /// present at the expected location.
391    ///
392    /// # Returns
393    ///
394    /// - `true` - A backup file exists and can potentially be restored
395    /// - `false` - No backup file found at the expected location
396    ///
397    /// # Examples
398    ///
399    /// ```rust,no_run
400    /// use agpm_cli::upgrade::backup::BackupManager;
401    /// use std::path::PathBuf;
402    ///
403    /// let manager = BackupManager::new(PathBuf::from("./agpm"));
404    ///
405    /// if manager.backup_exists() {
406    ///     println!("Backup available for rollback");
407    /// } else {
408    ///     println!("No backup found");
409    /// }
410    /// ```
411    ///
412    /// # Note
413    ///
414    /// This method only checks for file existence, not validity or integrity
415    /// of the backup file. Use [`restore_backup()`](Self::restore_backup) to
416    /// verify the backup can actually be used.
417    pub fn backup_exists(&self) -> bool {
418        self.backup_path.exists()
419    }
420
421    /// Get the path where the backup file is stored.
422    ///
423    /// Returns the full path to the backup file location, which is useful
424    /// for logging, debugging, or manual backup management.
425    ///
426    /// # Returns
427    ///
428    /// A path reference to the backup file location.
429    ///
430    /// # Examples
431    ///
432    /// ```rust,no_run
433    /// use agpm_cli::upgrade::backup::BackupManager;
434    /// use std::path::PathBuf;
435    ///
436    /// let manager = BackupManager::new(PathBuf::from("/usr/local/bin/agpm"));
437    /// println!("Backup location: {}", manager.backup_path().display());
438    /// // Output: Backup location: /usr/local/bin/agpm.backup
439    /// ```
440    ///
441    /// # Use Cases
442    ///
443    /// - **Logging**: Include backup location in log messages
444    /// - **Debugging**: Help users locate backup files manually
445    /// - **Error Messages**: Show backup location when operations fail
446    /// - **Manual Recovery**: Allow users to manually restore backups
447    pub fn backup_path(&self) -> &Path {
448        &self.backup_path
449    }
450}