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