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 for (name, destination) in [
307 ("cache", paths.cache_dir().to_path_buf()),
308 ("logs", paths.state_dir().join("logs")),
309 ("sessions", paths.state_dir().join("sessions")),
310 ("backups", paths.state_dir().join("backups")),
311 ] {
312 let source = paths.config_dir().join(name);
313 if source != legacy.join(name) {
314 mappings.push(LegacyMapping {
315 source,
316 destination,
317 skip: false,
318 excluded_children: &[],
319 });
320 }
321 }
322
323 for name in [
324 "model-cache",
325 "prompt-cache",
326 "approval-data",
327 "ast-grep",
328 "ast-grep.lock",
329 "web-fetch",
330 "large-output",
331 ] {
332 add_mapping(&mut mappings, legacy, name, paths.cache_dir().join(name));
333 }
334 add_mapping(&mut mappings, legacy, "cache", paths.cache_dir().to_path_buf());
337 add_mapping(&mut mappings, legacy, ".cache", paths.cache_dir().to_path_buf());
338
339 mappings.push(LegacyMapping {
340 source: legacy.join("state"),
341 destination: paths.state_dir().to_path_buf(),
342 skip: false,
343 excluded_children: &["migration"],
346 });
347 mappings.push(LegacyMapping {
348 source: legacy.join("tmp"),
349 destination: paths.runtime_dir().join("tmp"),
350 skip: true,
351 excluded_children: &[],
352 });
353 mappings
354}
355
356fn add_mapping(mappings: &mut Vec<LegacyMapping>, legacy: &Path, name: &str, destination: PathBuf) {
357 mappings.push(LegacyMapping {
358 source: legacy.join(name),
359 destination,
360 skip: false,
361 excluded_children: &[],
362 });
363}
364
365fn record_unmapped_entries(legacy_root: &Path, mappings: &[LegacyMapping], report: &mut MigrationReport) {
366 let entries = match fs::read_dir(legacy_root) {
367 Ok(entries) => entries,
368 Err(error) if error.kind() == io::ErrorKind::NotFound => return,
369 Err(error) => {
370 report.failures.push(MigrationFailure {
371 path: legacy_root.to_path_buf(),
372 error: format!("could not list legacy root: {error}"),
373 });
374 return;
375 }
376 };
377 for entry in entries {
378 let entry = match entry {
379 Ok(entry) => entry,
380 Err(error) => {
381 report.failures.push(MigrationFailure {
382 path: legacy_root.to_path_buf(),
383 error: format!("could not inspect legacy entry: {error}"),
384 });
385 continue;
386 }
387 };
388 if !mappings.iter().any(|mapping| mapping.source == entry.path()) {
389 report.skipped.push(MigrationSkip {
390 path: entry.path(),
391 reason: MigrationSkipReason::Unmapped,
392 });
393 }
394 }
395}
396
397fn copy_legacy_tree(source: &Path, destination: &Path, report: &mut MigrationReport) -> Result<()> {
398 copy_legacy_tree_with_exclusions(source, destination, report, &[])
399}
400
401fn copy_legacy_tree_with_exclusions(
402 source: &Path,
403 destination: &Path,
404 report: &mut MigrationReport,
405 excluded_children: &[&str],
406) -> Result<()> {
407 let metadata = match fs::symlink_metadata(source) {
408 Ok(metadata) => metadata,
409 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
410 Err(error) => return Err(error).with_context(|| format!("could not inspect legacy path {}", source.display())),
411 };
412 if metadata.file_type().is_symlink() {
413 report.skipped.push(MigrationSkip {
414 path: source.to_path_buf(),
415 reason: MigrationSkipReason::Symlink,
416 });
417 return Ok(());
418 }
419 if metadata.is_file() {
420 return copy_regular_file(source, destination, report);
421 }
422 if !metadata.is_dir() {
423 report.skipped.push(MigrationSkip {
424 path: source.to_path_buf(),
425 reason: MigrationSkipReason::SpecialFile,
426 });
427 return Ok(());
428 }
429 ensure_migration_dir(destination)?;
430 let entries =
431 fs::read_dir(source).with_context(|| format!("could not read legacy directory {}", source.display()))?;
432 for entry in entries {
433 let entry = match entry {
434 Ok(entry) => entry,
435 Err(error) => {
436 report.failures.push(MigrationFailure {
437 path: source.to_path_buf(),
438 error: format!("could not inspect legacy entry: {error}"),
439 });
440 continue;
441 }
442 };
443 let child_source = entry.path();
444 let child_destination = destination.join(entry.file_name());
445 if excluded_children.iter().any(|name| entry.file_name() == OsStr::new(name)) {
446 report.skipped.push(MigrationSkip {
447 path: child_source,
448 reason: MigrationSkipReason::Excluded,
449 });
450 continue;
451 }
452 if let Err(error) = copy_legacy_tree(&child_source, &child_destination, report) {
453 report
454 .failures
455 .push(MigrationFailure { path: child_source, error: error.to_string() });
456 }
457 }
458 Ok(())
459}
460
461fn copy_regular_file(source: &Path, destination: &Path, report: &mut MigrationReport) -> Result<()> {
462 if let Ok(metadata) = fs::symlink_metadata(destination) {
463 report.skipped.push(MigrationSkip {
464 path: source.to_path_buf(),
465 reason: if metadata.file_type().is_symlink() {
466 MigrationSkipReason::Symlink
467 } else {
468 MigrationSkipReason::DestinationExists
469 },
470 });
471 return Ok(());
472 }
473 let parent = destination
474 .parent()
475 .ok_or_else(|| anyhow!("migration destination {} has no parent", destination.display()))?;
476 ensure_migration_dir(parent)?;
477 let (temporary, mut output) = unique_private_file(parent, destination.file_name().unwrap_or(OsStr::new("file")))?;
478 let mut input =
479 open_no_follow(source).with_context(|| format!("could not safely open legacy file {}", source.display()))?;
480 let result: Result<()> = (|| {
481 let _bytes_copied = io::copy(&mut input, &mut output)?;
482 output.sync_all()?;
483 drop(output);
484 match fs::hard_link(&temporary, destination) {
485 Ok(()) => {
486 fs::remove_file(&temporary)?;
487 report.migrated.push(MigrationEntry {
488 source: source.to_path_buf(),
489 destination: destination.to_path_buf(),
490 });
491 Ok(())
492 }
493 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
494 report.skipped.push(MigrationSkip {
495 path: source.to_path_buf(),
496 reason: MigrationSkipReason::DestinationExists,
497 });
498 Ok(())
499 }
500 Err(error) => Err(error).with_context(|| format!("could not atomically publish {}", destination.display())),
501 }
502 })();
503 if result.is_err() {
504 remove_temporary_file(&temporary);
505 }
506 result
507}
508
509fn persist_report(paths: &VtCodePaths, report: &MigrationReport) -> Result<()> {
510 let serialized = serde_json::to_vec_pretty(report).context("could not serialize legacy migration report")?;
511 VtCodePaths::write_private_file_atomic(paths.migration_report_path(), &serialized)
512 .with_context(|| format!("could not write migration report {}", paths.migration_report_path().display()))
513}
514
515fn persist_report_best_effort(paths: &VtCodePaths, report: &MigrationReport) {
516 if let Err(error) = persist_report(paths, report) {
517 tracing::debug!(error = %error, "could not persist legacy migration report");
518 }
519}
520
521fn write_private_atomic(destination: &Path, contents: &[u8]) -> Result<()> {
522 let parent = destination
523 .parent()
524 .ok_or_else(|| anyhow!("private file {} has no parent", destination.display()))?;
525 ensure_private_dir(parent)?;
526 let (temporary, mut file) = unique_private_file(parent, destination.file_name().unwrap_or(OsStr::new("file")))?;
527 let result: io::Result<()> = (|| {
528 file.write_all(contents)?;
529 file.sync_all()?;
530 drop(file);
531 fs::hard_link(&temporary, destination)?;
532 fs::remove_file(&temporary)?;
533 Ok(())
534 })();
535 if result.is_err() {
536 remove_temporary_file(&temporary);
537 }
538 result.with_context(|| format!("could not publish {}", destination.display()))
539}
540
541fn valid_migration_marker(path: &Path) -> io::Result<bool> {
542 let metadata = fs::symlink_metadata(path)?;
543 if !metadata.is_file() || metadata.file_type().is_symlink() {
544 return Ok(false);
545 }
546 #[cfg(unix)]
547 {
548 use std::os::unix::fs::PermissionsExt;
549 if metadata.permissions().mode() & 0o077 != 0 {
550 return Ok(false);
551 }
552 }
553 let mut file = open_no_follow(path)?;
554 let mut contents = Vec::new();
555 let _bytes_read = file.read_to_end(&mut contents)?;
556 Ok(contents == b"legacy migration completed\n")
557}
558
559fn unique_private_file(parent: &Path, stem: &OsStr) -> Result<(PathBuf, File)> {
560 let timestamp = SystemTime::now()
561 .duration_since(UNIX_EPOCH)
562 .map(|duration| duration.as_nanos())
563 .unwrap_or_default();
564 let stem = stem.to_string_lossy();
565 for attempt in 0..32u8 {
566 let temporary = parent.join(format!(".{stem}.{}.{}.{}.migration", std::process::id(), timestamp, attempt));
567 match create_private_new_file(&temporary) {
568 Ok(file) => return Ok((temporary, file)),
569 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
570 Err(error) => return Err(error).with_context(|| format!("could not create {}", temporary.display())),
571 }
572 }
573 bail!("could not allocate a unique migration temporary file in {}", parent.display())
574}
575
576fn remove_temporary_file(path: &Path) {
577 if let Err(error) = fs::remove_file(path)
578 && error.kind() != io::ErrorKind::NotFound
579 {
580 tracing::debug!(path = %path.display(), %error, "failed to remove temporary migration file");
581 }
582}