vtcode_core/dotfile_protection/
backup.rs1#[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#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct DotfileBackup {
22 pub original_path: String,
24 pub backup_path: String,
26 pub created_at: DateTime<Utc>,
28 pub content_hash: String,
30 pub size_bytes: u64,
32 #[cfg(unix)]
34 pub permissions: u32,
35 pub reason: String,
37 pub session_id: String,
39}
40
41impl DotfileBackup {
42 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 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 tokio::fs::write(original_path, &content)
66 .await
67 .with_context(|| format!("Failed to restore to: {}", self.original_path))?;
68
69 #[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
90pub struct BackupManager {
92 backup_dir: PathBuf,
94 max_backups: usize,
96}
97
98impl BackupManager {
99 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 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 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 let content = tokio::fs::read(file_path)
127 .await
128 .with_context(|| format!("Failed to read file for backup: {:?}", file_path))?;
129
130 let metadata = tokio::fs::metadata(file_path)
132 .await
133 .with_context(|| format!("Failed to get metadata: {:?}", file_path))?;
134
135 let content_hash = calculate_sha256(&content);
137
138 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 tokio::fs::write(&backup_path, &content)
150 .await
151 .with_context(|| format!("Failed to write backup: {:?}", backup_path))?;
152
153 #[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 self.save_backup_metadata(&backup).await?;
179
180 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 fn safe_filename(&self, path: &Path) -> String {
190 path.to_string_lossy()
191 .replace(['/', '\\', ':', '.'], "_")
192 .trim_start_matches('_')
193 .to_string()
194 }
195
196 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 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 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 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 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 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 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 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 pub async fn list_all_backups(&self) -> Result<Vec<DotfileBackup>> {
293 self.load_backup_index().await
294 }
295
296 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 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 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 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 tokio::fs::write(&test_file, "modified content")
375 .await
376 .unwrap();
377
378 backup.restore().await.unwrap();
380
381 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 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}