Skip to main content

vtcode_core/dotfile_protection/
backup.rs

1//! Backup and restore functionality for dotfiles.
2//!
3//! Creates versioned backups before any permitted modification,
4//! preserving original permissions and ownership.
5
6#[cfg(unix)]
7use std::fs::Permissions;
8#[cfg(unix)]
9use std::os::unix::fs::PermissionsExt;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result, bail};
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use vtcode_commons::utils::calculate_sha256;
16
17use crate::utils::file_utils::{ensure_dir_exists, read_json_file, write_json_file};
18
19/// Metadata for a dotfile backup.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct DotfileBackup {
22    /// Original file path.
23    pub original_path: String,
24    /// Backup file path.
25    pub backup_path: String,
26    /// Timestamp of backup creation.
27    pub created_at: DateTime<Utc>,
28    /// SHA-256 hash of the original content.
29    pub content_hash: String,
30    /// Original file size in bytes.
31    pub size_bytes: u64,
32    /// Original file permissions (Unix mode).
33    #[cfg(unix)]
34    pub permissions: u32,
35    /// Reason for the backup.
36    pub reason: String,
37    /// Session that triggered the backup.
38    pub session_id: String,
39}
40
41impl DotfileBackup {
42    /// Restore this backup to the original location.
43    pub async fn restore(&self) -> Result<()> {
44        let backup_path = Path::new(&self.backup_path);
45        let original_path = Path::new(&self.original_path);
46
47        if !backup_path.exists() {
48            bail!("Backup file does not exist: {}", self.backup_path);
49        }
50
51        // Verify backup integrity
52        let content = tokio::fs::read(backup_path)
53            .await
54            .with_context(|| format!("Failed to read backup: {}", self.backup_path))?;
55
56        let hash = calculate_sha256(&content);
57        if hash != self.content_hash {
58            bail!(
59                "Backup integrity check failed: hash mismatch for {}",
60                self.backup_path
61            );
62        }
63
64        // Restore content
65        tokio::fs::write(original_path, &content)
66            .await
67            .with_context(|| format!("Failed to restore to: {}", self.original_path))?;
68
69        // Restore permissions
70        #[cfg(unix)]
71        {
72            let perms = Permissions::from_mode(self.permissions);
73            tokio::fs::set_permissions(original_path, perms)
74                .await
75                .with_context(|| {
76                    format!("Failed to restore permissions for: {}", self.original_path)
77                })?;
78        }
79
80        tracing::info!(
81            "Restored dotfile {} from backup {}",
82            self.original_path,
83            self.backup_path
84        );
85
86        Ok(())
87    }
88}
89
90/// Manager for dotfile backups.
91pub struct BackupManager {
92    /// Base directory for backups.
93    backup_dir: PathBuf,
94    /// Maximum backups to retain per file.
95    max_backups: usize,
96}
97
98impl BackupManager {
99    /// Create a new backup manager.
100    pub async fn new(backup_dir: impl AsRef<Path>, max_backups: usize) -> Result<Self> {
101        let backup_dir = backup_dir.as_ref().to_path_buf();
102
103        // Create backup directory if it doesn't exist
104        ensure_dir_exists(&backup_dir)
105            .await
106            .with_context(|| format!("Failed to create backup directory: {:?}", backup_dir))?;
107
108        Ok(Self {
109            backup_dir,
110            max_backups,
111        })
112    }
113
114    /// Create a backup of a dotfile before modification.
115    pub async fn create_backup(
116        &self,
117        file_path: &Path,
118        reason: impl Into<String>,
119        session_id: impl Into<String>,
120    ) -> Result<DotfileBackup> {
121        if !file_path.exists() {
122            bail!("Cannot backup non-existent file: {:?}", file_path);
123        }
124
125        // Read original content
126        let content = tokio::fs::read(file_path)
127            .await
128            .with_context(|| format!("Failed to read file for backup: {:?}", file_path))?;
129
130        // Get file metadata
131        let metadata = tokio::fs::metadata(file_path)
132            .await
133            .with_context(|| format!("Failed to get metadata: {:?}", file_path))?;
134
135        // Compute content hash
136        let content_hash = calculate_sha256(&content);
137
138        // Generate backup path
139        let timestamp = Utc::now();
140        let safe_name = self.safe_filename(file_path);
141        let backup_filename = format!(
142            "{}.{}.backup",
143            safe_name,
144            timestamp.format("%Y%m%d_%H%M%S_%3f")
145        );
146        let backup_path = self.backup_dir.join(&backup_filename);
147
148        // Write backup
149        tokio::fs::write(&backup_path, &content)
150            .await
151            .with_context(|| format!("Failed to write backup: {:?}", backup_path))?;
152
153        // Preserve permissions on backup
154        #[cfg(unix)]
155        {
156            let perms = metadata.permissions();
157            tokio::fs::set_permissions(&backup_path, perms.clone())
158                .await
159                .with_context(|| format!("Failed to set backup permissions: {:?}", backup_path))?;
160        }
161
162        #[cfg(unix)]
163        let permissions = metadata.permissions().mode();
164
165        let backup = DotfileBackup {
166            original_path: file_path.to_string_lossy().into_owned(),
167            backup_path: backup_path.to_string_lossy().into_owned(),
168            created_at: timestamp,
169            content_hash,
170            size_bytes: metadata.len(),
171            #[cfg(unix)]
172            permissions,
173            reason: reason.into(),
174            session_id: session_id.into(),
175        };
176
177        // Save backup metadata
178        self.save_backup_metadata(&backup).await?;
179
180        // Cleanup old backups
181        self.cleanup_old_backups(file_path).await?;
182
183        tracing::info!("Created backup for {:?} at {:?}", file_path, backup_path);
184
185        Ok(backup)
186    }
187
188    /// Convert a file path to a safe filename for backup.
189    fn safe_filename(&self, path: &Path) -> String {
190        path.to_string_lossy()
191            .replace(['/', '\\', ':', '.'], "_")
192            .trim_start_matches('_')
193            .to_string()
194    }
195
196    /// Save backup metadata to a JSON index.
197    async fn save_backup_metadata(&self, backup: &DotfileBackup) -> Result<()> {
198        let index_path = self.backup_dir.join("backups.json");
199
200        let mut backups = self.load_backup_index().await.unwrap_or_default();
201        backups.push(backup.clone());
202
203        write_json_file(&index_path, &backups)
204            .await
205            .with_context(|| format!("Failed to write backup index: {:?}", index_path))?;
206
207        Ok(())
208    }
209
210    /// Load the backup index.
211    async fn load_backup_index(&self) -> Result<Vec<DotfileBackup>> {
212        let index_path = self.backup_dir.join("backups.json");
213
214        if !index_path.exists() {
215            return Ok(Vec::new());
216        }
217
218        let backups: Vec<DotfileBackup> = read_json_file(&index_path)
219            .await
220            .with_context(|| format!("Failed to parse backup index: {:?}", index_path))?;
221
222        Ok(backups)
223    }
224
225    /// Cleanup old backups, keeping only the most recent N.
226    async fn cleanup_old_backups(&self, file_path: &Path) -> Result<()> {
227        let backups = self.load_backup_index().await?;
228        let file_path_str = file_path.to_string_lossy();
229
230        // Get backups for this file, sorted by date (newest first)
231        let mut file_backups: Vec<_> = backups
232            .iter()
233            .filter(|b| b.original_path == file_path_str)
234            .collect();
235
236        file_backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
237
238        // Delete old backups beyond max_backups
239        for backup in file_backups.iter().skip(self.max_backups) {
240            let backup_path = Path::new(&backup.backup_path);
241            if backup_path.exists() {
242                if let Err(e) = tokio::fs::remove_file(backup_path).await {
243                    tracing::warn!("Failed to remove old backup {:?}: {}", backup_path, e);
244                } else {
245                    tracing::debug!("Removed old backup: {:?}", backup_path);
246                }
247            }
248        }
249
250        // Update index (remove deleted backups)
251        let remaining: Vec<_> = backups
252            .into_iter()
253            .filter(|b| {
254                if b.original_path == file_path_str {
255                    Path::new(&b.backup_path).exists()
256                } else {
257                    true
258                }
259            })
260            .collect();
261
262        let index_path = self.backup_dir.join("backups.json");
263        write_json_file(&index_path, &remaining)
264            .await
265            .with_context(|| "Failed to update backup index")?;
266
267        Ok(())
268    }
269
270    /// Get all backups for a specific file.
271    pub async fn get_backups_for_file(&self, file_path: &Path) -> Result<Vec<DotfileBackup>> {
272        let backups = self.load_backup_index().await?;
273        let file_path_str = file_path.to_string_lossy();
274
275        let mut file_backups: Vec<_> = backups
276            .into_iter()
277            .filter(|b| b.original_path == file_path_str)
278            .collect();
279
280        file_backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
281
282        Ok(file_backups)
283    }
284
285    /// Get the most recent backup for a file.
286    pub async fn get_latest_backup(&self, file_path: &Path) -> Result<Option<DotfileBackup>> {
287        let backups = self.get_backups_for_file(file_path).await?;
288        Ok(backups.into_iter().next())
289    }
290
291    /// List all backups.
292    pub async fn list_all_backups(&self) -> Result<Vec<DotfileBackup>> {
293        self.load_backup_index().await
294    }
295
296    /// Restore the most recent backup for a file.
297    pub async fn restore_latest(&self, file_path: &Path) -> Result<()> {
298        let backup = self
299            .get_latest_backup(file_path)
300            .await?
301            .ok_or_else(|| anyhow::anyhow!("No backup found for: {:?}", file_path))?;
302
303        backup.restore().await
304    }
305
306    /// Verify integrity of all backups.
307    pub async fn verify_all_backups(&self) -> Result<Vec<(DotfileBackup, bool)>> {
308        let backups = self.load_backup_index().await?;
309        let mut results = Vec::new();
310
311        for backup in backups {
312            let backup_path = Path::new(&backup.backup_path);
313            let valid = if backup_path.exists() {
314                match tokio::fs::read(backup_path).await {
315                    Ok(content) => {
316                        let hash = calculate_sha256(&content);
317                        hash == backup.content_hash
318                    }
319                    Err(_) => false,
320                }
321            } else {
322                false
323            };
324            results.push((backup, valid));
325        }
326
327        Ok(results)
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use tempfile::tempdir;
335
336    #[tokio::test]
337    async fn test_backup_creation() {
338        let dir = tempdir().unwrap();
339        let backup_dir = dir.path().join("backups");
340        let test_file = dir.path().join(".testrc");
341
342        // Create test file
343        tokio::fs::write(&test_file, "test content").await.unwrap();
344
345        let manager = BackupManager::new(&backup_dir, 5).await.unwrap();
346        let backup = manager
347            .create_backup(&test_file, "test backup", "test-session")
348            .await
349            .unwrap();
350
351        assert_eq!(backup.original_path, test_file.to_string_lossy());
352        assert!(Path::new(&backup.backup_path).exists());
353    }
354
355    #[tokio::test]
356    async fn test_backup_restore() {
357        let dir = tempdir().unwrap();
358        let backup_dir = dir.path().join("backups");
359        let test_file = dir.path().join(".testrc");
360
361        // Create test file with original content
362        let original_content = "original content";
363        tokio::fs::write(&test_file, original_content)
364            .await
365            .unwrap();
366
367        let manager = BackupManager::new(&backup_dir, 5).await.unwrap();
368        let backup = manager
369            .create_backup(&test_file, "before modification", "test-session")
370            .await
371            .unwrap();
372
373        // Modify the file
374        tokio::fs::write(&test_file, "modified content")
375            .await
376            .unwrap();
377
378        // Restore from backup
379        backup.restore().await.unwrap();
380
381        // Verify content is restored
382        let restored = tokio::fs::read_to_string(&test_file).await.unwrap();
383        assert_eq!(restored, original_content);
384    }
385
386    #[tokio::test]
387    async fn test_backup_cleanup() {
388        let dir = tempdir().unwrap();
389        let backup_dir = dir.path().join("backups");
390        let test_file = dir.path().join(".testrc");
391
392        tokio::fs::write(&test_file, "test").await.unwrap();
393
394        let manager = BackupManager::new(&backup_dir, 2).await.unwrap();
395
396        // Create 5 backups (should keep only 2)
397        for i in 0..5 {
398            tokio::fs::write(&test_file, format!("content {}", i))
399                .await
400                .unwrap();
401            manager
402                .create_backup(&test_file, format!("backup {}", i), "test-session")
403                .await
404                .unwrap();
405            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
406        }
407
408        let backups = manager.get_backups_for_file(&test_file).await.unwrap();
409        assert_eq!(backups.len(), 2);
410    }
411}