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(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        // Migration metadata is owned by this protocol. Copying a legacy
327        // marker could make an incomplete scan look completed.
328        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}