Skip to main content

vtcode_commons/
vtcode_paths_migration.rs

1//! Rollback-safe migration from the pre-XDG `~/.vtcode` layout.
2
3use 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/// Performs one idempotent migration using an immutable path-policy snapshot.
15#[derive(Debug, Clone)]
16pub struct LegacyMigrator {
17    paths: VtCodePaths,
18}
19
20impl LegacyMigrator {
21    /// Creates a migrator bound to one resolved global path policy.
22    pub fn new(paths: VtCodePaths) -> Self {
23        Self { paths }
24    }
25
26    /// Scans and copies eligible legacy content without modifying its source.
27    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        // Persist the complete scan report before publishing the completion
149        // marker. If diagnostics cannot be recorded, retry the migration
150        // rather than claiming a successful one-time migration.
151        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/// Outcome of a legacy migration attempt.
193#[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    /// Returns whether a later startup should retry the migration.
204    pub fn has_retryable_failures(&self) -> bool {
205        !self.failures.is_empty()
206    }
207}
208
209/// A source file copied by migration.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
211pub struct MigrationEntry {
212    pub source: PathBuf,
213    pub destination: PathBuf,
214}
215
216/// A legacy item that was deliberately left untouched.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
218pub struct MigrationSkip {
219    pub path: PathBuf,
220    pub reason: MigrationSkipReason,
221}
222
223/// An individual migration failure; remaining entries are still scanned.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
225pub struct MigrationFailure {
226    pub path: PathBuf,
227    pub error: String,
228}
229
230/// Reason a legacy item was not copied.
231#[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    // Before the centralized path policy, DotManager kept cache, logs,
314    // sessions, and backups directly below the configuration directory. On
315    // native platforms that directory is still the current config root, so
316    // those files are outside the legacy-home scan above and need explicit
317    // compatibility mappings.
318    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    // Keep the pre-XDG configuration root ahead of the legacy home cache when
347    // both layouts exist; it is the most recent location used by DotManager.
348    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        // Migration metadata is owned by this protocol. Copying a legacy
356        // marker could make an incomplete scan look completed.
357        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}