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    // Before the centralized path policy, DotManager kept cache, logs,
302    // sessions, and backups directly below the configuration directory. On
303    // native platforms that directory is still the current config root, so
304    // those files are outside the legacy-home scan above and need explicit
305    // compatibility mappings.
306    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    // Keep the pre-XDG configuration root ahead of the legacy home cache when
335    // both layouts exist; it is the most recent location used by DotManager.
336    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        // Migration metadata is owned by this protocol. Copying a legacy
344        // marker could make an incomplete scan look completed.
345        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}