1use std::ffi::OsStr;
4use std::fs::{self, File};
5use std::io::{self, Read, Write};
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use anyhow::{Context, Result, anyhow, bail};
10use serde::Serialize;
11
12use super::{VtCodePaths, create_private_new_file, ensure_migration_dir, ensure_private_dir, open_no_follow};
13
14#[derive(Debug, Clone)]
16pub struct LegacyMigrator {
17 paths: VtCodePaths,
18}
19
20impl LegacyMigrator {
21 pub fn new(paths: VtCodePaths) -> Self {
23 Self { paths }
24 }
25
26 pub fn run(&self) -> Result<MigrationReport> {
28 let marker = self.paths.migration_marker_path();
29 let mut report = MigrationReport::default();
30 let marker_parent = marker.parent().ok_or_else(|| anyhow!("migration marker has no parent"))?;
31 if let Err(error) = ensure_private_dir(marker_parent) {
32 report.failures.push(MigrationFailure {
33 path: marker_parent.to_path_buf(),
34 error: error.to_string(),
35 });
36 persist_report_best_effort(&self.paths, &report);
37 return Ok(report);
38 }
39 let marker_blocked = match fs::symlink_metadata(&marker) {
40 Ok(metadata) if metadata.file_type().is_symlink() => {
41 report.failures.push(MigrationFailure {
42 path: marker.clone(),
43 error: "migration marker is a symlink; refusing to follow it".to_string(),
44 });
45 true
46 }
47 Ok(metadata) if metadata.is_file() => match valid_migration_marker(&marker) {
48 Ok(true) => {
49 return Ok(MigrationReport {
50 already_completed: true,
51 ..MigrationReport::default()
52 });
53 }
54 Ok(false) => {
55 report.failures.push(MigrationFailure {
56 path: marker.clone(),
57 error: "migration marker has invalid contents or permissions".to_string(),
58 });
59 true
60 }
61 Err(error) => {
62 report.failures.push(MigrationFailure {
63 path: marker.clone(),
64 error: format!("could not validate migration marker: {error}"),
65 });
66 true
67 }
68 },
69 Ok(_) => {
70 report.failures.push(MigrationFailure {
71 path: marker.clone(),
72 error: "migration marker is not a regular file".to_string(),
73 });
74 true
75 }
76 Err(error) if error.kind() == io::ErrorKind::NotFound => false,
77 Err(error) => {
78 report.failures.push(MigrationFailure {
79 path: marker.clone(),
80 error: format!("could not inspect migration marker: {error}"),
81 });
82 true
83 }
84 };
85
86 let legacy_root = self.paths.legacy_home_dir();
87 let legacy_root_is_safe = match fs::symlink_metadata(legacy_root) {
88 Ok(metadata) if metadata.file_type().is_symlink() => {
89 report.failures.push(MigrationFailure {
90 path: legacy_root.to_path_buf(),
91 error: "legacy root is a symlink; refusing to traverse it".to_string(),
92 });
93 false
94 }
95 Ok(metadata) if !metadata.is_dir() => {
96 report.failures.push(MigrationFailure {
97 path: legacy_root.to_path_buf(),
98 error: "legacy root is not a directory".to_string(),
99 });
100 false
101 }
102 Ok(_) => true,
103 Err(error) if error.kind() == io::ErrorKind::NotFound => true,
104 Err(error) => {
105 report.failures.push(MigrationFailure {
106 path: legacy_root.to_path_buf(),
107 error: format!("could not inspect legacy root: {error}"),
108 });
109 false
110 }
111 };
112
113 if legacy_root_is_safe {
114 let mappings = legacy_mappings(&self.paths);
115 for mapping in &mappings {
116 if mapping.source == mapping.destination {
117 continue;
118 }
119 if mapping.skip {
120 if fs::symlink_metadata(&mapping.source).is_ok() {
121 report.skipped.push(MigrationSkip {
122 path: mapping.source.clone(),
123 reason: MigrationSkipReason::Excluded,
124 });
125 }
126 continue;
127 }
128 if let Err(error) = copy_legacy_tree_with_exclusions(
129 &mapping.source,
130 &mapping.destination,
131 &mut report,
132 mapping.excluded_children,
133 ) {
134 report.failures.push(MigrationFailure {
135 path: mapping.source.clone(),
136 error: error.to_string(),
137 });
138 }
139 }
140 record_unmapped_entries(legacy_root, &mappings, &mut report);
141 }
142
143 if report.has_retryable_failures() || marker_blocked {
144 persist_report_best_effort(&self.paths, &report);
145 return Ok(report);
146 }
147
148 if let Err(error) = persist_report(&self.paths, &report) {
152 report.failures.push(MigrationFailure {
153 path: self.paths.migration_report_path(),
154 error: error.to_string(),
155 });
156 persist_report_best_effort(&self.paths, &report);
157 return Ok(report);
158 }
159
160 if let Err(error) = write_private_atomic(&marker, b"legacy migration completed\n") {
161 if error
162 .downcast_ref::<io::Error>()
163 .is_some_and(|io_error| io_error.kind() == io::ErrorKind::AlreadyExists)
164 {
165 if valid_migration_marker(&marker).unwrap_or(false) {
166 report.already_completed = true;
167 } else {
168 report.failures.push(MigrationFailure {
169 path: marker,
170 error: "migration marker appeared but is invalid".to_string(),
171 });
172 }
173 return Ok(report);
174 }
175 report
176 .failures
177 .push(MigrationFailure { path: marker, error: error.to_string() });
178 persist_report_best_effort(&self.paths, &report);
179 return Ok(report);
180 }
181 report.marker_written = true;
182 if let Err(error) = persist_report(&self.paths, &report) {
183 report.failures.push(MigrationFailure {
184 path: self.paths.migration_report_path(),
185 error: error.to_string(),
186 });
187 }
188 Ok(report)
189 }
190}
191
192#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
194pub struct MigrationReport {
195 pub migrated: Vec<MigrationEntry>,
196 pub skipped: Vec<MigrationSkip>,
197 pub failures: Vec<MigrationFailure>,
198 pub marker_written: bool,
199 pub already_completed: bool,
200}
201
202impl MigrationReport {
203 pub fn has_retryable_failures(&self) -> bool {
205 !self.failures.is_empty()
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
211pub struct MigrationEntry {
212 pub source: PathBuf,
213 pub destination: PathBuf,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
218pub struct MigrationSkip {
219 pub path: PathBuf,
220 pub reason: MigrationSkipReason,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
225pub struct MigrationFailure {
226 pub path: PathBuf,
227 pub error: String,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
232#[serde(rename_all = "snake_case")]
233pub enum MigrationSkipReason {
234 DestinationExists,
235 Symlink,
236 SpecialFile,
237 Excluded,
238 Unmapped,
239}
240
241struct LegacyMapping {
242 source: PathBuf,
243 destination: PathBuf,
244 skip: bool,
245 excluded_children: &'static [&'static str],
246}
247
248fn legacy_mappings(paths: &VtCodePaths) -> Vec<LegacyMapping> {
249 let legacy = paths.legacy_home_dir();
250 let mut mappings = Vec::with_capacity(50);
251 for name in [
252 "vtcode.toml",
253 "update.toml",
254 "config.toml",
255 "AGENTS.md",
256 "AGENTS.override.md",
257 "CLAUDE.md",
258 "commands",
259 "agents",
260 "rules",
261 "prompts",
262 "tool-policy.json",
263 "mcp.json",
264 "mcp.toml",
265 "mcp-config.json",
266 "mcp-config.toml",
267 "mcp",
268 "auth",
269 "output-styles",
270 "output_styles",
271 ] {
272 add_mapping(&mut mappings, legacy, name, paths.config_dir().join(name));
273 }
274 add_mapping(&mut mappings, legacy, "auth.json", paths.auth_file());
275 for name in [
276 "plugins",
277 "skills",
278 "installed-skills",
279 "assets",
280 "durable-assets",
281 "tools",
282 ] {
283 add_mapping(&mut mappings, legacy, name, paths.data_dir().join(name));
284 }
285 add_mapping(&mut mappings, legacy, "bin", paths.executable_dir().to_path_buf());
286 for name in [
287 "projects",
288 "sessions",
289 "history",
290 "memory",
291 "agent-memory",
292 "audit",
293 "logs",
294 "scheduler",
295 "pods",
296 "checkpoints",
297 "backups",
298 ] {
299 add_mapping(&mut mappings, legacy, name, paths.state_dir().join(name));
300 }
301 add_mapping(
302 &mut mappings,
303 legacy,
304 "ast_grep_install_cache.json",
305 paths.cache_dir().join("ast-grep/install.json"),
306 );
307 add_mapping(
308 &mut mappings,
309 legacy,
310 "ripgrep_install_cache.json",
311 paths.cache_dir().join("ripgrep/ripgrep_install_cache.json"),
312 );
313 for (name, destination) in [
319 ("cache", paths.cache_dir().to_path_buf()),
320 ("logs", paths.state_dir().join("logs")),
321 ("sessions", paths.state_dir().join("sessions")),
322 ("backups", paths.state_dir().join("backups")),
323 ] {
324 let source = paths.config_dir().join(name);
325 if source != legacy.join(name) {
326 mappings.push(LegacyMapping {
327 source,
328 destination,
329 skip: false,
330 excluded_children: &[],
331 });
332 }
333 }
334
335 for name in [
336 "model-cache",
337 "prompt-cache",
338 "approval-data",
339 "ast-grep",
340 "ast-grep.lock",
341 "web-fetch",
342 "large-output",
343 ] {
344 add_mapping(&mut mappings, legacy, name, paths.cache_dir().join(name));
345 }
346 add_mapping(&mut mappings, legacy, "cache", paths.cache_dir().to_path_buf());
349 add_mapping(&mut mappings, legacy, ".cache", paths.cache_dir().to_path_buf());
350
351 mappings.push(LegacyMapping {
352 source: legacy.join("state"),
353 destination: paths.state_dir().to_path_buf(),
354 skip: false,
355 excluded_children: &["migration"],
358 });
359 mappings.push(LegacyMapping {
360 source: legacy.join("tmp"),
361 destination: paths.runtime_dir().join("tmp"),
362 skip: true,
363 excluded_children: &[],
364 });
365 mappings
366}
367
368fn add_mapping(mappings: &mut Vec<LegacyMapping>, legacy: &Path, name: &str, destination: PathBuf) {
369 mappings.push(LegacyMapping {
370 source: legacy.join(name),
371 destination,
372 skip: false,
373 excluded_children: &[],
374 });
375}
376
377fn record_unmapped_entries(legacy_root: &Path, mappings: &[LegacyMapping], report: &mut MigrationReport) {
378 let entries = match fs::read_dir(legacy_root) {
379 Ok(entries) => entries,
380 Err(error) if error.kind() == io::ErrorKind::NotFound => return,
381 Err(error) => {
382 report.failures.push(MigrationFailure {
383 path: legacy_root.to_path_buf(),
384 error: format!("could not list legacy root: {error}"),
385 });
386 return;
387 }
388 };
389 for entry in entries {
390 let entry = match entry {
391 Ok(entry) => entry,
392 Err(error) => {
393 report.failures.push(MigrationFailure {
394 path: legacy_root.to_path_buf(),
395 error: format!("could not inspect legacy entry: {error}"),
396 });
397 continue;
398 }
399 };
400 if !mappings.iter().any(|mapping| mapping.source == entry.path()) {
401 report.skipped.push(MigrationSkip {
402 path: entry.path(),
403 reason: MigrationSkipReason::Unmapped,
404 });
405 }
406 }
407}
408
409fn copy_legacy_tree(source: &Path, destination: &Path, report: &mut MigrationReport) -> Result<()> {
410 copy_legacy_tree_with_exclusions(source, destination, report, &[])
411}
412
413fn copy_legacy_tree_with_exclusions(
414 source: &Path,
415 destination: &Path,
416 report: &mut MigrationReport,
417 excluded_children: &[&str],
418) -> Result<()> {
419 let metadata = match fs::symlink_metadata(source) {
420 Ok(metadata) => metadata,
421 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
422 Err(error) => return Err(error).with_context(|| format!("could not inspect legacy path {}", source.display())),
423 };
424 if metadata.file_type().is_symlink() {
425 report.skipped.push(MigrationSkip {
426 path: source.to_path_buf(),
427 reason: MigrationSkipReason::Symlink,
428 });
429 return Ok(());
430 }
431 if metadata.is_file() {
432 return copy_regular_file(source, destination, report);
433 }
434 if !metadata.is_dir() {
435 report.skipped.push(MigrationSkip {
436 path: source.to_path_buf(),
437 reason: MigrationSkipReason::SpecialFile,
438 });
439 return Ok(());
440 }
441 ensure_migration_dir(destination)?;
442 let entries =
443 fs::read_dir(source).with_context(|| format!("could not read legacy directory {}", source.display()))?;
444 for entry in entries {
445 let entry = match entry {
446 Ok(entry) => entry,
447 Err(error) => {
448 report.failures.push(MigrationFailure {
449 path: source.to_path_buf(),
450 error: format!("could not inspect legacy entry: {error}"),
451 });
452 continue;
453 }
454 };
455 let child_source = entry.path();
456 let child_destination = destination.join(entry.file_name());
457 if excluded_children.iter().any(|name| entry.file_name() == OsStr::new(name)) {
458 report.skipped.push(MigrationSkip {
459 path: child_source,
460 reason: MigrationSkipReason::Excluded,
461 });
462 continue;
463 }
464 if let Err(error) = copy_legacy_tree(&child_source, &child_destination, report) {
465 report
466 .failures
467 .push(MigrationFailure { path: child_source, error: error.to_string() });
468 }
469 }
470 Ok(())
471}
472
473fn copy_regular_file(source: &Path, destination: &Path, report: &mut MigrationReport) -> Result<()> {
474 if let Ok(metadata) = fs::symlink_metadata(destination) {
475 report.skipped.push(MigrationSkip {
476 path: source.to_path_buf(),
477 reason: if metadata.file_type().is_symlink() {
478 MigrationSkipReason::Symlink
479 } else {
480 MigrationSkipReason::DestinationExists
481 },
482 });
483 return Ok(());
484 }
485 let parent = destination
486 .parent()
487 .ok_or_else(|| anyhow!("migration destination {} has no parent", destination.display()))?;
488 ensure_migration_dir(parent)?;
489 let (temporary, mut output) = unique_private_file(parent, destination.file_name().unwrap_or(OsStr::new("file")))?;
490 let mut input =
491 open_no_follow(source).with_context(|| format!("could not safely open legacy file {}", source.display()))?;
492 let result: Result<()> = (|| {
493 let _bytes_copied = io::copy(&mut input, &mut output)?;
494 output.sync_all()?;
495 drop(output);
496 match fs::hard_link(&temporary, destination) {
497 Ok(()) => {
498 fs::remove_file(&temporary)?;
499 report.migrated.push(MigrationEntry {
500 source: source.to_path_buf(),
501 destination: destination.to_path_buf(),
502 });
503 Ok(())
504 }
505 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
506 report.skipped.push(MigrationSkip {
507 path: source.to_path_buf(),
508 reason: MigrationSkipReason::DestinationExists,
509 });
510 Ok(())
511 }
512 Err(error) => Err(error).with_context(|| format!("could not atomically publish {}", destination.display())),
513 }
514 })();
515 if result.is_err() {
516 remove_temporary_file(&temporary);
517 }
518 result
519}
520
521fn persist_report(paths: &VtCodePaths, report: &MigrationReport) -> Result<()> {
522 let serialized = serde_json::to_vec_pretty(report).context("could not serialize legacy migration report")?;
523 VtCodePaths::write_private_file_atomic(paths.migration_report_path(), &serialized)
524 .with_context(|| format!("could not write migration report {}", paths.migration_report_path().display()))
525}
526
527fn persist_report_best_effort(paths: &VtCodePaths, report: &MigrationReport) {
528 if let Err(error) = persist_report(paths, report) {
529 tracing::debug!(error = %error, "could not persist legacy migration report");
530 }
531}
532
533fn write_private_atomic(destination: &Path, contents: &[u8]) -> Result<()> {
534 let parent = destination
535 .parent()
536 .ok_or_else(|| anyhow!("private file {} has no parent", destination.display()))?;
537 ensure_private_dir(parent)?;
538 let (temporary, mut file) = unique_private_file(parent, destination.file_name().unwrap_or(OsStr::new("file")))?;
539 let result: io::Result<()> = (|| {
540 file.write_all(contents)?;
541 file.sync_all()?;
542 drop(file);
543 fs::hard_link(&temporary, destination)?;
544 fs::remove_file(&temporary)?;
545 Ok(())
546 })();
547 if result.is_err() {
548 remove_temporary_file(&temporary);
549 }
550 result.with_context(|| format!("could not publish {}", destination.display()))
551}
552
553fn valid_migration_marker(path: &Path) -> io::Result<bool> {
554 let metadata = fs::symlink_metadata(path)?;
555 if !metadata.is_file() || metadata.file_type().is_symlink() {
556 return Ok(false);
557 }
558 #[cfg(unix)]
559 {
560 use std::os::unix::fs::PermissionsExt;
561 if metadata.permissions().mode() & 0o077 != 0 {
562 return Ok(false);
563 }
564 }
565 let mut file = open_no_follow(path)?;
566 let mut contents = Vec::new();
567 let _bytes_read = file.read_to_end(&mut contents)?;
568 Ok(contents == b"legacy migration completed\n")
569}
570
571fn unique_private_file(parent: &Path, stem: &OsStr) -> Result<(PathBuf, File)> {
572 let timestamp = SystemTime::now()
573 .duration_since(UNIX_EPOCH)
574 .map(|duration| duration.as_nanos())
575 .unwrap_or_default();
576 let stem = stem.to_string_lossy();
577 for attempt in 0..32u8 {
578 let temporary = parent.join(format!(".{stem}.{}.{}.{}.migration", std::process::id(), timestamp, attempt));
579 match create_private_new_file(&temporary) {
580 Ok(file) => return Ok((temporary, file)),
581 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
582 Err(error) => return Err(error).with_context(|| format!("could not create {}", temporary.display())),
583 }
584 }
585 bail!("could not allocate a unique migration temporary file in {}", parent.display())
586}
587
588fn remove_temporary_file(path: &Path) {
589 if let Err(error) = fs::remove_file(path)
590 && error.kind() != io::ErrorKind::NotFound
591 {
592 tracing::debug!(path = %path.display(), %error, "failed to remove temporary migration file");
593 }
594}