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::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#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct DotfileBackup {
24 pub original_path: String,
26 pub backup_path: String,
28 pub created_at: DateTime<Utc>,
30 pub content_hash: String,
32 pub size_bytes: u64,
34 #[cfg(unix)]
36 pub permissions: u32,
37 pub reason: String,
39 pub session_id: String,
41}
42
43impl DotfileBackup {
44 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 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 write_private_file_atomic(original_path, &content)
68 .await
69 .with_context(|| format!("Failed to restore to: {}", self.original_path))?;
70
71 #[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
86pub struct BackupManager {
88 backup_dir: PathBuf,
90 max_backups: usize,
92}
93
94impl BackupManager {
95 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 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 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 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 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 let content_hash = calculate_sha256(&content);
128
129 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_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 self.save_backup_metadata(&backup).await?;
157
158 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 fn safe_filename(&self, path: &Path) -> String {
168 path.to_string_lossy()
169 .replace(['/', '\\', ':', '.'], "_")
170 .trim_start_matches('_')
171 .to_string()
172 }
173
174 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 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 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 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 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 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 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 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 pub async fn list_all_backups(&self) -> Result<Vec<DotfileBackup>> {
273 self.load_backup_index().await
274 }
275
276 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 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 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 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 tokio::fs::write(&test_file, "modified content").await.unwrap();
358
359 backup.restore().await.unwrap();
361
362 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 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}