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(44);
251 let mut add = |name: &str, destination: PathBuf| {
252 mappings.push(LegacyMapping {
253 source: legacy.join(name),
254 destination,
255 skip: false,
256 excluded_children: &[],
257 });
258 };
259 for name in [
260 "vtcode.toml",
261 "update.toml",
262 "config.toml",
263 "AGENTS.md",
264 "AGENTS.override.md",
265 "CLAUDE.md",
266 "commands",
267 "agents",
268 "rules",
269 "prompts",
270 "tool-policy.json",
271 "mcp.json",
272 "mcp.toml",
273 "mcp-config.json",
274 "mcp-config.toml",
275 "mcp",
276 "auth",
277 "output-styles",
278 "output_styles",
279 ] {
280 add(name, paths.config_dir().join(name));
281 }
282 add("auth.json", paths.auth_file());
283 for name in [
284 "plugins",
285 "skills",
286 "installed-skills",
287 "assets",
288 "durable-assets",
289 "tools",
290 ] {
291 add(name, paths.data_dir().join(name));
292 }
293 add("bin", paths.executable_dir().to_path_buf());
294 for name in [
295 "projects",
296 "sessions",
297 "history",
298 "memory",
299 "agent-memory",
300 "audit",
301 "logs",
302 "scheduler",
303 "pods",
304 "checkpoints",
305 "backups",
306 ] {
307 add(name, paths.state_dir().join(name));
308 }
309 for name in [
310 "model-cache",
311 "prompt-cache",
312 "approval-data",
313 "ast-grep",
314 "ast-grep.lock",
315 "web-fetch",
316 "large-output",
317 ] {
318 add(name, paths.cache_dir().join(name));
319 }
320 add("cache", paths.cache_dir().to_path_buf());
321 add(".cache", paths.cache_dir().to_path_buf());
322 mappings.push(LegacyMapping {
323 source: legacy.join("state"),
324 destination: paths.state_dir().to_path_buf(),
325 skip: false,
326 excluded_children: &["migration"],
329 });
330 mappings.push(LegacyMapping {
331 source: legacy.join("tmp"),
332 destination: paths.runtime_dir().join("tmp"),
333 skip: true,
334 excluded_children: &[],
335 });
336 mappings
337}
338
339fn record_unmapped_entries(legacy_root: &Path, mappings: &[LegacyMapping], report: &mut MigrationReport) {
340 let entries = match fs::read_dir(legacy_root) {
341 Ok(entries) => entries,
342 Err(error) if error.kind() == io::ErrorKind::NotFound => return,
343 Err(error) => {
344 report.failures.push(MigrationFailure {
345 path: legacy_root.to_path_buf(),
346 error: format!("could not list legacy root: {error}"),
347 });
348 return;
349 }
350 };
351 for entry in entries {
352 let entry = match entry {
353 Ok(entry) => entry,
354 Err(error) => {
355 report.failures.push(MigrationFailure {
356 path: legacy_root.to_path_buf(),
357 error: format!("could not inspect legacy entry: {error}"),
358 });
359 continue;
360 }
361 };
362 if !mappings.iter().any(|mapping| mapping.source == entry.path()) {
363 report.skipped.push(MigrationSkip {
364 path: entry.path(),
365 reason: MigrationSkipReason::Unmapped,
366 });
367 }
368 }
369}
370
371fn copy_legacy_tree(source: &Path, destination: &Path, report: &mut MigrationReport) -> Result<()> {
372 copy_legacy_tree_with_exclusions(source, destination, report, &[])
373}
374
375fn copy_legacy_tree_with_exclusions(
376 source: &Path,
377 destination: &Path,
378 report: &mut MigrationReport,
379 excluded_children: &[&str],
380) -> Result<()> {
381 let metadata = match fs::symlink_metadata(source) {
382 Ok(metadata) => metadata,
383 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
384 Err(error) => return Err(error).with_context(|| format!("could not inspect legacy path {}", source.display())),
385 };
386 if metadata.file_type().is_symlink() {
387 report.skipped.push(MigrationSkip {
388 path: source.to_path_buf(),
389 reason: MigrationSkipReason::Symlink,
390 });
391 return Ok(());
392 }
393 if metadata.is_file() {
394 return copy_regular_file(source, destination, report);
395 }
396 if !metadata.is_dir() {
397 report.skipped.push(MigrationSkip {
398 path: source.to_path_buf(),
399 reason: MigrationSkipReason::SpecialFile,
400 });
401 return Ok(());
402 }
403 ensure_migration_dir(destination)?;
404 let entries =
405 fs::read_dir(source).with_context(|| format!("could not read legacy directory {}", source.display()))?;
406 for entry in entries {
407 let entry = match entry {
408 Ok(entry) => entry,
409 Err(error) => {
410 report.failures.push(MigrationFailure {
411 path: source.to_path_buf(),
412 error: format!("could not inspect legacy entry: {error}"),
413 });
414 continue;
415 }
416 };
417 let child_source = entry.path();
418 let child_destination = destination.join(entry.file_name());
419 if excluded_children.iter().any(|name| entry.file_name() == OsStr::new(name)) {
420 report.skipped.push(MigrationSkip {
421 path: child_source,
422 reason: MigrationSkipReason::Excluded,
423 });
424 continue;
425 }
426 if let Err(error) = copy_legacy_tree(&child_source, &child_destination, report) {
427 report
428 .failures
429 .push(MigrationFailure { path: child_source, error: error.to_string() });
430 }
431 }
432 Ok(())
433}
434
435fn copy_regular_file(source: &Path, destination: &Path, report: &mut MigrationReport) -> Result<()> {
436 if let Ok(metadata) = fs::symlink_metadata(destination) {
437 report.skipped.push(MigrationSkip {
438 path: source.to_path_buf(),
439 reason: if metadata.file_type().is_symlink() {
440 MigrationSkipReason::Symlink
441 } else {
442 MigrationSkipReason::DestinationExists
443 },
444 });
445 return Ok(());
446 }
447 let parent = destination
448 .parent()
449 .ok_or_else(|| anyhow!("migration destination {} has no parent", destination.display()))?;
450 ensure_migration_dir(parent)?;
451 let (temporary, mut output) = unique_private_file(parent, destination.file_name().unwrap_or(OsStr::new("file")))?;
452 let mut input =
453 open_no_follow(source).with_context(|| format!("could not safely open legacy file {}", source.display()))?;
454 let result: Result<()> = (|| {
455 let _bytes_copied = io::copy(&mut input, &mut output)?;
456 output.sync_all()?;
457 drop(output);
458 match fs::hard_link(&temporary, destination) {
459 Ok(()) => {
460 fs::remove_file(&temporary)?;
461 report.migrated.push(MigrationEntry {
462 source: source.to_path_buf(),
463 destination: destination.to_path_buf(),
464 });
465 Ok(())
466 }
467 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
468 report.skipped.push(MigrationSkip {
469 path: source.to_path_buf(),
470 reason: MigrationSkipReason::DestinationExists,
471 });
472 Ok(())
473 }
474 Err(error) => Err(error).with_context(|| format!("could not atomically publish {}", destination.display())),
475 }
476 })();
477 if result.is_err() {
478 remove_temporary_file(&temporary);
479 }
480 result
481}
482
483fn persist_report(paths: &VtCodePaths, report: &MigrationReport) -> Result<()> {
484 let serialized = serde_json::to_vec_pretty(report).context("could not serialize legacy migration report")?;
485 VtCodePaths::write_private_file_atomic(paths.migration_report_path(), &serialized)
486 .with_context(|| format!("could not write migration report {}", paths.migration_report_path().display()))
487}
488
489fn persist_report_best_effort(paths: &VtCodePaths, report: &MigrationReport) {
490 if let Err(error) = persist_report(paths, report) {
491 tracing::debug!(error = %error, "could not persist legacy migration report");
492 }
493}
494
495fn write_private_atomic(destination: &Path, contents: &[u8]) -> Result<()> {
496 let parent = destination
497 .parent()
498 .ok_or_else(|| anyhow!("private file {} has no parent", destination.display()))?;
499 ensure_private_dir(parent)?;
500 let (temporary, mut file) = unique_private_file(parent, destination.file_name().unwrap_or(OsStr::new("file")))?;
501 let result: io::Result<()> = (|| {
502 file.write_all(contents)?;
503 file.sync_all()?;
504 drop(file);
505 fs::hard_link(&temporary, destination)?;
506 fs::remove_file(&temporary)?;
507 Ok(())
508 })();
509 if result.is_err() {
510 remove_temporary_file(&temporary);
511 }
512 result.with_context(|| format!("could not publish {}", destination.display()))
513}
514
515fn valid_migration_marker(path: &Path) -> io::Result<bool> {
516 let metadata = fs::symlink_metadata(path)?;
517 if !metadata.is_file() || metadata.file_type().is_symlink() {
518 return Ok(false);
519 }
520 #[cfg(unix)]
521 {
522 use std::os::unix::fs::PermissionsExt;
523 if metadata.permissions().mode() & 0o077 != 0 {
524 return Ok(false);
525 }
526 }
527 let mut file = open_no_follow(path)?;
528 let mut contents = Vec::new();
529 let _bytes_read = file.read_to_end(&mut contents)?;
530 Ok(contents == b"legacy migration completed\n")
531}
532
533fn unique_private_file(parent: &Path, stem: &OsStr) -> Result<(PathBuf, File)> {
534 let timestamp = SystemTime::now()
535 .duration_since(UNIX_EPOCH)
536 .map(|duration| duration.as_nanos())
537 .unwrap_or_default();
538 let stem = stem.to_string_lossy();
539 for attempt in 0..32u8 {
540 let temporary = parent.join(format!(".{stem}.{}.{}.{}.migration", std::process::id(), timestamp, attempt));
541 match create_private_new_file(&temporary) {
542 Ok(file) => return Ok((temporary, file)),
543 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
544 Err(error) => return Err(error).with_context(|| format!("could not create {}", temporary.display())),
545 }
546 }
547 bail!("could not allocate a unique migration temporary file in {}", parent.display())
548}
549
550fn remove_temporary_file(path: &Path) {
551 if let Err(error) = fs::remove_file(path)
552 && error.kind() != io::ErrorKind::NotFound
553 {
554 tracing::debug!(path = %path.display(), %error, "failed to remove temporary migration file");
555 }
556}