Skip to main content

vtcode_commons/
vtcode_paths.rs

1//! Native and XDG-compliant storage paths for VT Code.
2
3use std::collections::BTreeMap;
4use std::ffi::{OsStr, OsString};
5use std::fs::{self, File, OpenOptions};
6use std::io::{self, Read, Write};
7use std::path::{Component, Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use anyhow::{Context, Result, anyhow, bail};
11
12#[path = "vtcode_paths_migration.rs"]
13mod migration;
14pub use migration::{
15    LegacyMigrator, MigrationEntry, MigrationFailure, MigrationReport, MigrationSkip, MigrationSkipReason,
16};
17
18const APP: &str = "vtcode";
19const MARKER: &str = "legacy-v1.complete";
20
21struct NativeRoots {
22    config_dir: PathBuf,
23    data_dir: PathBuf,
24    state_dir: PathBuf,
25    cache_dir: PathBuf,
26    runtime_dir: Option<PathBuf>,
27    executable_dir: PathBuf,
28}
29
30fn native_roots(
31    #[cfg_attr(
32        not(any(target_os = "macos", target_os = "windows")),
33        allow(
34            unused_variables,
35            reason = "home_dir is only consumed by macOS/Windows root resolution"
36        )
37    )]
38    home_dir: &Path,
39) -> Result<NativeRoots> {
40    #[cfg(target_os = "macos")]
41    {
42        let root = dirs::data_local_dir()
43            .ok_or_else(|| anyhow!("could not determine the macOS application support directory"))?
44            .join("com.vinhnx.vtcode");
45        Ok(NativeRoots {
46            config_dir: root.clone(),
47            data_dir: root.clone(),
48            state_dir: root.join("state"),
49            cache_dir: dirs::cache_dir()
50                .ok_or_else(|| anyhow!("could not determine the macOS cache directory"))?
51                .join("com.vinhnx.vtcode"),
52            runtime_dir: None,
53            executable_dir: home_dir.join(".local/bin"),
54        })
55    }
56    #[cfg(target_os = "windows")]
57    {
58        let root = dirs::data_dir()
59            .ok_or_else(|| anyhow!("could not determine the Windows application data directory"))?
60            .join("vinhnx")
61            .join(APP);
62        Ok(NativeRoots {
63            config_dir: root.join("config"),
64            data_dir: root.join("data"),
65            state_dir: root.join("state"),
66            cache_dir: root.join("cache"),
67            runtime_dir: None,
68            executable_dir: root.join("bin"),
69        })
70    }
71    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
72    {
73        Ok(NativeRoots {
74            config_dir: home_dir.join(".config").join(APP),
75            data_dir: home_dir.join(".local/share").join(APP),
76            state_dir: home_dir.join(".local/state").join(APP),
77            cache_dir: home_dir.join(".cache").join(APP),
78            runtime_dir: None,
79            executable_dir: home_dir.join(".local/bin"),
80        })
81    }
82}
83
84/// Validated native/XDG storage roots and typed VT Code child paths.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct VtCodePaths {
87    config_dir: PathBuf,
88    data_dir: PathBuf,
89    state_dir: PathBuf,
90    cache_dir: PathBuf,
91    runtime_dir: PathBuf,
92    executable_dir: PathBuf,
93    system_config_dirs: Vec<PathBuf>,
94    system_data_dirs: Vec<PathBuf>,
95    legacy_home_dir: PathBuf,
96}
97
98impl VtCodePaths {
99    /// Resolves paths from the process environment.
100    pub fn from_env() -> Result<Self> {
101        Self::from_environment_os(&std::env::vars_os().collect())
102    }
103
104    /// Alias for [`Self::from_env`], useful during startup resolution.
105    pub fn resolve() -> Result<Self> {
106        Self::from_env()
107    }
108
109    /// Resolves paths from an explicit environment, suitable for deterministic tests.
110    pub fn from_environment(environment: &[(&str, &str)]) -> Result<Self> {
111        Self::from_environment_os(
112            &environment
113                .iter()
114                .map(|(key, value)| (OsString::from(key), OsString::from(value)))
115                .collect(),
116        )
117    }
118
119    fn from_environment_os(environment: &BTreeMap<OsString, OsString>) -> Result<Self> {
120        let home_dir = environment
121            .get(OsStr::new("HOME"))
122            .filter(|value| !value.is_empty())
123            .map(PathBuf::from)
124            .filter(|path| path.is_absolute())
125            .or_else(dirs::home_dir)
126            .ok_or_else(|| anyhow!("could not determine the user home directory"))?;
127        let native = native_roots(&home_dir)?;
128        let home_override = env_path(environment, "VTCODE_HOME");
129        if let Some(path) = &home_override {
130            validate_absolute("VTCODE_HOME", path)?;
131        }
132        let legacy_home_dir = home_override.clone().unwrap_or_else(|| home_dir.join(".vtcode"));
133
134        let config_dir = match env_path(environment, "VTCODE_CONFIG") {
135            Some(path) => {
136                validate_absolute("VTCODE_CONFIG", &path)?;
137                path
138            }
139            None => xdg_app_dir(environment, "XDG_CONFIG_HOME", &native.config_dir)?,
140        };
141        let data_dir = match env_path(environment, "VTCODE_DATA") {
142            Some(path) => {
143                validate_absolute("VTCODE_DATA", &path)?;
144                path
145            }
146            None => xdg_app_dir(environment, "XDG_DATA_HOME", &native.data_dir)?,
147        };
148        let state_dir = xdg_app_dir(environment, "XDG_STATE_HOME", &native.state_dir)?;
149        let cache_dir = xdg_app_dir(environment, "XDG_CACHE_HOME", &native.cache_dir)?;
150        let runtime_dir = match () {
151            _ if is_xdg_platform() => match env_path(environment, "XDG_RUNTIME_DIR") {
152                Some(path) if path.is_absolute() => path.join(APP),
153                None => state_dir.join("runtime"),
154                Some(_) => state_dir.join("runtime"),
155            },
156            _ => native.runtime_dir.unwrap_or_else(|| state_dir.join("runtime")),
157        };
158        for (name, path) in [
159            ("configuration directory", &config_dir),
160            ("data directory", &data_dir),
161            ("state directory", &state_dir),
162            ("cache directory", &cache_dir),
163            ("runtime directory", &runtime_dir),
164        ] {
165            validate_absolute(name, path)?;
166        }
167        Ok(Self {
168            config_dir,
169            data_dir,
170            state_dir,
171            cache_dir,
172            runtime_dir,
173            executable_dir: executable_dir(environment, native.executable_dir)?,
174            system_config_dirs: system_config_dirs(environment)?,
175            system_data_dirs: system_data_dirs(environment)?,
176            legacy_home_dir,
177        })
178    }
179
180    /// Canonical directory for user configuration.
181    pub fn config_dir(&self) -> &Path {
182        &self.config_dir
183    }
184    /// Canonical directory for durable user data.
185    pub fn data_dir(&self) -> &Path {
186        &self.data_dir
187    }
188    /// Canonical directory for durable mutable state.
189    pub fn state_dir(&self) -> &Path {
190        &self.state_dir
191    }
192    /// Canonical directory for recreatable cached data.
193    pub fn cache_dir(&self) -> &Path {
194        &self.cache_dir
195    }
196    /// Private directory for runtime-only files.
197    pub fn runtime_dir(&self) -> &Path {
198        &self.runtime_dir
199    }
200    /// User executable helper directory.
201    pub fn executable_dir(&self) -> &Path {
202        &self.executable_dir
203    }
204    /// System configuration candidates, lowest priority first.
205    pub fn system_config_dirs(&self) -> &[PathBuf] {
206        &self.system_config_dirs
207    }
208    /// System data candidates without an application suffix.
209    pub fn system_data_dirs(&self) -> &[PathBuf] {
210        &self.system_data_dirs
211    }
212    /// Resolves a relative config path against all system candidates in
213    /// low-to-high precedence order. `system_config_dirs()` retains the XDG
214    /// preference order, where the first directory is most important.
215    pub fn system_config_paths(&self, relative: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
216        let relative = relative.as_ref();
217        validate_relative_path("system configuration path", relative)?;
218        let mut paths = if cfg!(unix) {
219            vec![PathBuf::from("/etc/vtcode").join(relative)]
220        } else {
221            Vec::new()
222        };
223        paths.extend(self.system_config_dirs.iter().rev().map(|base| base.join(APP).join(relative)));
224        paths.dedup();
225        Ok(paths)
226    }
227    /// Resolves a relative data path against all system candidates.
228    pub fn system_data_paths(&self, relative: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
229        let relative = relative.as_ref();
230        validate_relative_path("system data path", relative)?;
231        Ok(self.system_data_dirs.iter().map(|base| base.join(APP).join(relative)).collect())
232    }
233    /// Legacy global VT Code root, used only as a migration source.
234    pub fn legacy_home_dir(&self) -> &Path {
235        &self.legacy_home_dir
236    }
237
238    /// Alias for [`Self::legacy_home_dir`] using the public contract name.
239    pub fn legacy_dir(&self) -> &Path {
240        self.legacy_home_dir()
241    }
242
243    /// Resolve a relative child path under the canonical configuration root.
244    pub fn config_path(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
245        child_path(&self.config_dir, relative.as_ref(), "configuration")
246    }
247
248    /// Resolve a relative child path under the canonical data root.
249    pub fn data_path(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
250        child_path(&self.data_dir, relative.as_ref(), "data")
251    }
252
253    /// Resolve a relative child path under the persistent state root.
254    pub fn state_path(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
255        child_path(&self.state_dir, relative.as_ref(), "state")
256    }
257
258    /// Resolve a relative child path under the cache root.
259    pub fn cache_path(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
260        child_path(&self.cache_dir, relative.as_ref(), "cache")
261    }
262
263    /// Resolve a relative child path under the runtime root.
264    pub fn runtime_path(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
265        child_path(&self.runtime_dir, relative.as_ref(), "runtime")
266    }
267
268    /// Resolve a relative child path under the managed executable root.
269    pub fn executable_path(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
270        child_path(&self.executable_dir, relative.as_ref(), "executable")
271    }
272
273    /// Main user configuration file.
274    pub fn config_file(&self) -> PathBuf {
275        self.config_dir.join("vtcode.toml")
276    }
277    /// User-installed and downloaded skills.
278    pub fn skills_dir(&self) -> PathBuf {
279        self.data_dir.join("skills")
280    }
281    /// User-installed plugins.
282    pub fn plugins_dir(&self) -> PathBuf {
283        self.data_dir.join("plugins")
284    }
285    /// Private authentication storage.
286    pub fn auth_dir(&self) -> PathBuf {
287        self.config_dir.join("auth")
288    }
289    /// Legacy plaintext authentication file location.
290    pub fn auth_file(&self) -> PathBuf {
291        self.auth_dir().join("auth.json")
292    }
293    /// Durable log files.
294    pub fn logs_dir(&self) -> PathBuf {
295        self.state_dir.join("logs")
296    }
297    /// Persisted cross-workspace session data.
298    pub fn sessions_dir(&self) -> PathBuf {
299        self.state_dir.join("sessions")
300    }
301    /// Telemetry storage.
302    pub fn telemetry_dir(&self) -> PathBuf {
303        self.state_dir.join("telemetry")
304    }
305    /// Completion marker for one-time legacy migration.
306    pub fn migration_marker_path(&self) -> PathBuf {
307        self.state_dir.join("migration").join(MARKER)
308    }
309
310    /// Versioned diagnostic report emitted alongside the completion marker.
311    pub fn migration_report_path(&self) -> PathBuf {
312        self.state_dir.join("migration").join("legacy-v1.json")
313    }
314
315    /// Creates the runtime directory with private permissions.
316    pub fn ensure_runtime_dir(&self) -> Result<&Path> {
317        ensure_private_dir(&self.runtime_dir).context("could not create VT Code runtime directory")?;
318        Ok(&self.runtime_dir)
319    }
320
321    /// Creates the canonical user configuration directory when needed.
322    /// Existing directories retain their permissions.
323    pub fn ensure_config_dir(&self) -> Result<&Path> {
324        ensure_user_dir(&self.config_dir).context("could not create VT Code configuration directory")?;
325        Ok(&self.config_dir)
326    }
327
328    /// Creates the canonical user data directory when needed.
329    /// Existing directories retain their permissions.
330    pub fn ensure_data_dir(&self) -> Result<&Path> {
331        ensure_user_dir(&self.data_dir).context("could not create VT Code data directory")?;
332        Ok(&self.data_dir)
333    }
334
335    /// Creates the canonical user state directory when needed.
336    /// Existing directories retain their permissions.
337    pub fn ensure_state_dir(&self) -> Result<&Path> {
338        ensure_user_dir(&self.state_dir).context("could not create VT Code state directory")?;
339        Ok(&self.state_dir)
340    }
341
342    /// Creates the canonical user cache directory when needed.
343    /// Existing directories retain their permissions.
344    pub fn ensure_cache_dir(&self) -> Result<&Path> {
345        ensure_user_dir(&self.cache_dir).context("could not create VT Code cache directory")?;
346        Ok(&self.cache_dir)
347    }
348
349    /// Creates the managed executable directory when needed.
350    /// Existing directories retain their permissions.
351    pub fn ensure_executable_dir(&self) -> Result<&Path> {
352        ensure_user_dir(&self.executable_dir).context("could not create VT Code executable directory")?;
353        Ok(&self.executable_dir)
354    }
355
356    /// Creates an arbitrary user-owned directory without following symlinks.
357    /// Existing directories retain their permissions; newly created Unix
358    /// directories use mode `0700`.
359    pub fn ensure_user_dir(path: impl AsRef<Path>) -> Result<PathBuf> {
360        let path = path.as_ref();
361        ensure_user_dir(path).with_context(|| format!("could not create user directory {}", path.display()))?;
362        Ok(path.to_path_buf())
363    }
364
365    /// Creates a new private file without following a final symlink.
366    ///
367    /// The parent is validated component by component before the file is
368    /// opened. This is the common primitive for caches, locks, and other
369    /// category-owned files that must never be redirected through a symlink.
370    pub fn create_private_file(path: impl AsRef<Path>) -> Result<File> {
371        let path = path.as_ref();
372        ensure_file_parent(path)?;
373        create_private_new_file(path).with_context(|| format!("could not create private file {}", path.display()))
374    }
375
376    /// Opens an append-only private file without following a final symlink.
377    pub fn open_private_append_file(path: impl AsRef<Path>) -> Result<File> {
378        let path = path.as_ref();
379        ensure_file_parent(path)?;
380        open_private_append(path).with_context(|| format!("could not open private file {}", path.display()))
381    }
382
383    /// Reads a regular file without following a final symlink.
384    pub fn read_file_no_follow(path: impl AsRef<Path>) -> Result<Vec<u8>> {
385        let path = path.as_ref();
386        validate_no_escaping_symlink_ancestors(path, false)
387            .with_context(|| format!("could not validate file path {}", path.display()))?;
388        let mut file = open_no_follow(path).with_context(|| format!("could not open file {}", path.display()))?;
389        let metadata = file
390            .metadata()
391            .with_context(|| format!("could not inspect file {}", path.display()))?;
392        if !metadata.is_file() {
393            bail!("{} is not a regular file", path.display());
394        }
395        let mut contents = Vec::new();
396        let _bytes_read = file
397            .read_to_end(&mut contents)
398            .with_context(|| format!("could not read file {}", path.display()))?;
399        Ok(contents)
400    }
401
402    /// Atomically writes a private file, replacing an existing regular file.
403    ///
404    /// The destination is never opened for writing. A private, exclusive
405    /// temporary file is written and then renamed into place, so a pre-existing
406    /// symlink cannot redirect the contents outside the approved parent.
407    pub fn write_private_file_atomic(path: impl AsRef<Path>, contents: &[u8]) -> Result<()> {
408        let destination = path.as_ref();
409        ensure_file_parent(destination)?;
410        validate_file_destination(destination)?;
411        let parent = destination
412            .parent()
413            .ok_or_else(|| anyhow!("private file {} has no parent", destination.display()))?;
414        let stem = destination.file_name().unwrap_or_else(|| OsStr::new("file"));
415        let (temporary, mut file) = unique_private_file(parent, stem)?;
416        let result: io::Result<()> = (|| {
417            file.write_all(contents)?;
418            file.sync_all()?;
419            drop(file);
420            #[cfg(windows)]
421            if fs::symlink_metadata(destination).is_ok() {
422                fs::remove_file(destination)?;
423            }
424            fs::rename(&temporary, destination)
425        })();
426        if result.is_err() {
427            remove_temporary_file(&temporary);
428        }
429        result.with_context(|| format!("could not atomically write {}", destination.display()))
430    }
431
432    /// Creates a private runtime child directory after validating its path.
433    pub fn ensure_runtime_child_dir(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
434        let path = self.runtime_path(relative)?;
435        ensure_private_dir(&path).context("could not create VT Code runtime child directory")?;
436        Ok(path)
437    }
438
439    /// Creates a user configuration child directory after validating its path.
440    pub fn ensure_config_child_dir(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
441        let path = self.config_path(relative)?;
442        ensure_user_dir(&path).context("could not create VT Code configuration child directory")?;
443        Ok(path)
444    }
445
446    /// Creates a user data child directory after validating its path.
447    pub fn ensure_data_child_dir(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
448        let path = self.data_path(relative)?;
449        ensure_user_dir(&path).context("could not create VT Code data child directory")?;
450        Ok(path)
451    }
452
453    /// Creates a user state child directory after validating its path.
454    pub fn ensure_state_child_dir(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
455        let path = self.state_path(relative)?;
456        ensure_user_dir(&path).context("could not create VT Code state child directory")?;
457        Ok(path)
458    }
459
460    /// Creates a user cache child directory after validating its path.
461    pub fn ensure_cache_child_dir(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
462        let path = self.cache_path(relative)?;
463        ensure_user_dir(&path).context("could not create VT Code cache child directory")?;
464        Ok(path)
465    }
466
467    /// Creates a managed executable child directory after validating its path.
468    pub fn ensure_executable_child_dir(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
469        let path = self.executable_path(relative)?;
470        ensure_user_dir(&path).context("could not create VT Code executable child directory")?;
471        Ok(path)
472    }
473
474    /// Creates private auth storage and returns its path.
475    pub fn ensure_auth_dir(&self) -> Result<PathBuf> {
476        let path = self.auth_dir();
477        ensure_private_dir(&path).context("could not create VT Code authentication directory")?;
478        Ok(path)
479    }
480
481    /// Creates an empty, private auth file. The name must not contain a path.
482    pub fn create_auth_file(&self, name: impl AsRef<str>) -> Result<PathBuf> {
483        let name = name.as_ref();
484        if !is_safe_file_name(name) {
485            bail!("authentication file name '{name}' must be one normal path component");
486        }
487        let path = self.ensure_auth_dir()?.join(name);
488        let _file = create_private_new_file(&path)
489            .with_context(|| format!("could not create authentication file {}", path.display()))?;
490        Ok(path)
491    }
492
493    /// Copies eligible legacy global data without modifying its source.
494    pub fn migrate_legacy(&self) -> Result<MigrationReport> {
495        LegacyMigrator::new(self.clone()).run()
496    }
497}
498
499fn env_path(environment: &BTreeMap<OsString, OsString>, name: &str) -> Option<PathBuf> {
500    environment
501        .get(OsStr::new(name))
502        .filter(|value| !value.is_empty())
503        .and_then(|value| {
504            if let Some(text) = value.to_str() {
505                let trimmed = text.trim();
506                (!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
507            } else {
508                Some(PathBuf::from(value))
509            }
510        })
511}
512fn xdg_app_dir(environment: &BTreeMap<OsString, OsString>, name: &str, native: &Path) -> Result<PathBuf> {
513    if is_xdg_platform()
514        && let Some(path) = env_path(environment, name)
515        && path.is_absolute()
516    {
517        return Ok(path.join(APP));
518    }
519    Ok(native.to_path_buf())
520}
521fn executable_dir(environment: &BTreeMap<OsString, OsString>, native: PathBuf) -> Result<PathBuf> {
522    if is_xdg_platform()
523        && let Some(path) = env_path(environment, "XDG_BIN_HOME")
524        && path.is_absolute()
525    {
526        return Ok(path);
527    }
528    Ok(native)
529}
530fn system_config_dirs(environment: &BTreeMap<OsString, OsString>) -> Result<Vec<PathBuf>> {
531    if !is_xdg_platform() {
532        return Ok(Vec::new());
533    }
534    let configured = environment.get(OsStr::new("XDG_CONFIG_DIRS")).map(OsString::as_os_str);
535    let mut paths = configured
536        .into_iter()
537        .flat_map(std::env::split_paths)
538        .filter(|path| path.is_absolute())
539        .collect::<Vec<_>>();
540    if paths.is_empty() {
541        paths.push(PathBuf::from("/etc/xdg"));
542    }
543    Ok(paths)
544}
545fn system_data_dirs(environment: &BTreeMap<OsString, OsString>) -> Result<Vec<PathBuf>> {
546    if !is_xdg_platform() {
547        return Ok(Vec::new());
548    }
549    let configured = environment.get(OsStr::new("XDG_DATA_DIRS")).map(OsString::as_os_str);
550    let mut paths = configured
551        .into_iter()
552        .flat_map(std::env::split_paths)
553        .filter(|path| path.is_absolute())
554        .collect::<Vec<_>>();
555    if paths.is_empty() {
556        paths.extend([PathBuf::from("/usr/local/share"), PathBuf::from("/usr/share")]);
557    }
558    Ok(paths)
559}
560fn validate_absolute(name: &str, path: &Path) -> Result<()> {
561    if path.is_absolute() {
562        Ok(())
563    } else {
564        bail!("{name} must be an absolute path, got '{}'", path.display())
565    }
566}
567fn validate_relative_path(name: &str, path: &Path) -> Result<()> {
568    if !path.as_os_str().is_empty() && path.components().all(|component| matches!(component, Component::Normal(_))) {
569        Ok(())
570    } else {
571        bail!("{name} must be a non-empty relative path without traversal, got '{}'", path.display())
572    }
573}
574
575fn child_path(root: &Path, relative: &Path, category: &str) -> Result<PathBuf> {
576    validate_relative_path(&format!("{category} child path"), relative)?;
577    Ok(root.join(relative))
578}
579const fn is_xdg_platform() -> bool {
580    cfg!(any(
581        target_os = "linux",
582        target_os = "freebsd",
583        target_os = "netbsd",
584        target_os = "openbsd",
585        target_os = "dragonfly"
586    ))
587}
588fn is_safe_file_name(name: &str) -> bool {
589    let mut parts = Path::new(name).components();
590    matches!(parts.next(), Some(Component::Normal(_))) && parts.next().is_none()
591}
592fn ensure_private_dir(path: &Path) -> io::Result<()> {
593    match fs::symlink_metadata(path) {
594        Ok(metadata) if metadata.file_type().is_symlink() => {
595            return Err(io::Error::other(format!("refusing symlink directory {}", path.display())));
596        }
597        Ok(metadata) if !metadata.is_dir() => {
598            return Err(io::Error::other(format!("{} is not a directory", path.display())));
599        }
600        Ok(_) => {}
601        Err(error) if error.kind() == io::ErrorKind::NotFound => {
602            ensure_private_parent_dir(path)?;
603            fs::create_dir(path)?;
604        }
605        Err(error) => return Err(error),
606    }
607    set_private_permissions(path)
608}
609
610/// Ensure a user-owned directory exists without changing the mode of any
611/// directory that was already present. Every directory created by this
612/// helper is private on Unix.
613fn ensure_user_dir(path: &Path) -> io::Result<()> {
614    validate_no_escaping_symlink_ancestors(path, true)?;
615    match fs::symlink_metadata(path) {
616        Ok(metadata) if metadata.file_type().is_symlink() => {
617            return Err(io::Error::other(format!("refusing symlink directory {}", path.display())));
618        }
619        Ok(metadata) if !metadata.is_dir() => {
620            return Err(io::Error::other(format!("{} is not a directory", path.display())));
621        }
622        Ok(_) => return Ok(()),
623        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
624        Err(error) => return Err(error),
625    }
626
627    let Some(parent) = path.parent() else {
628        return Err(io::Error::other(format!("{} has no parent directory", path.display())));
629    };
630    if parent != path {
631        ensure_user_dir(parent)?;
632    }
633    match fs::symlink_metadata(path) {
634        Ok(metadata) if metadata.file_type().is_symlink() => {
635            Err(io::Error::other(format!("refusing symlink directory {}", path.display())))
636        }
637        Ok(metadata) if !metadata.is_dir() => Err(io::Error::other(format!("{} is not a directory", path.display()))),
638        Ok(_) => Ok(()),
639        Err(error) if error.kind() == io::ErrorKind::NotFound => {
640            fs::create_dir(path)?;
641            set_private_permissions(path)
642        }
643        Err(error) => Err(error),
644    }
645}
646
647fn ensure_private_parent_dir(path: &Path) -> io::Result<()> {
648    validate_no_escaping_symlink_ancestors(path, true)?;
649    let Some(parent) = path.parent() else {
650        return Ok(());
651    };
652    if parent == path {
653        return Ok(());
654    }
655    match fs::symlink_metadata(parent) {
656        Ok(metadata) if metadata.file_type().is_symlink() => {
657            Err(io::Error::other(format!("refusing symlink directory {}", parent.display())))
658        }
659        Ok(metadata) if !metadata.is_dir() => Err(io::Error::other(format!("{} is not a directory", parent.display()))),
660        Ok(_) => Ok(()),
661        Err(error) if error.kind() == io::ErrorKind::NotFound => {
662            ensure_private_parent_dir(parent)?;
663            fs::create_dir(parent)?;
664            set_private_permissions(parent)
665        }
666        Err(error) => Err(error),
667    }
668}
669
670/// Ensure migration-created directories are private while preserving the mode
671/// of directories that already existed at the destination.
672fn ensure_migration_dir(path: &Path) -> io::Result<()> {
673    ensure_user_dir(path)
674}
675fn create_private_new_file(path: &Path) -> io::Result<File> {
676    let mut options = OpenOptions::new();
677    let _ = options.write(true).create_new(true);
678    #[cfg(unix)]
679    {
680        use std::os::unix::fs::OpenOptionsExt;
681        let _ = options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
682    }
683    options.open(path)
684}
685fn open_no_follow(path: &Path) -> io::Result<File> {
686    let mut options = OpenOptions::new();
687    let _ = options.read(true);
688    #[cfg(unix)]
689    {
690        use std::os::unix::fs::OpenOptionsExt;
691        let _ = options.custom_flags(libc::O_NOFOLLOW);
692    }
693    options.open(path)
694}
695
696fn open_private_append(path: &Path) -> io::Result<File> {
697    let mut options = OpenOptions::new();
698    let _ = options.create(true).append(true).write(true);
699    #[cfg(unix)]
700    {
701        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
702        let _ = options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
703        let file = options.open(path)?;
704        file.set_permissions(fs::Permissions::from_mode(0o600))?;
705        Ok(file)
706    }
707    #[cfg(not(unix))]
708    options.open(path)
709}
710
711fn ensure_file_parent(path: &Path) -> Result<()> {
712    let parent = path
713        .parent()
714        .ok_or_else(|| anyhow!("file {} has no parent directory", path.display()))?;
715    ensure_user_dir(parent).with_context(|| format!("could not create file parent {}", parent.display()))?;
716    Ok(())
717}
718
719fn validate_file_destination(path: &Path) -> Result<()> {
720    match fs::symlink_metadata(path) {
721        Ok(metadata) if metadata.file_type().is_symlink() => {
722            bail!("refusing to replace symlinked file {}", path.display())
723        }
724        Ok(metadata) if !metadata.is_file() => bail!("{} is not a regular file", path.display()),
725        Ok(_) => Ok(()),
726        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
727        Err(error) => Err(error).with_context(|| format!("could not inspect {}", path.display())),
728    }
729}
730
731/// Validate every existing path component and reject escaping symlinks.
732///
733/// Platform aliases such as macOS `/tmp -> /private/tmp` are accepted when a
734/// symlink resolves beneath its containing directory. Missing trailing
735/// components are allowed so callers can create them safely afterwards.
736fn validate_no_escaping_symlink_ancestors(path: &Path, allow_missing_tail: bool) -> io::Result<()> {
737    let components = path.components().collect::<Vec<_>>();
738    let mut current = PathBuf::new();
739    for (index, component) in components.iter().enumerate() {
740        if matches!(component, Component::ParentDir) {
741            return Err(io::Error::other(format!("path contains traversal: {}", path.display())));
742        }
743        if matches!(component, Component::CurDir) {
744            continue;
745        }
746        current.push(component.as_os_str());
747        let is_leaf = index + 1 == components.len();
748        let metadata = match fs::symlink_metadata(&current) {
749            Ok(metadata) => metadata,
750            Err(error) if allow_missing_tail && error.kind() == io::ErrorKind::NotFound => break,
751            Err(error) => return Err(error),
752        };
753        if metadata.file_type().is_symlink() {
754            if is_leaf {
755                return Err(io::Error::other(format!("refusing symlink path {}", current.display())));
756            }
757            let parent = current
758                .parent()
759                .filter(|parent| !parent.as_os_str().is_empty())
760                .unwrap_or_else(|| Path::new("."));
761            let canonical_parent = crate::canonicalize(parent)?;
762            let canonical_target = crate::canonicalize(&current)?;
763            if !canonical_target.starts_with(&canonical_parent) {
764                return Err(io::Error::other(format!(
765                    "path component {} escapes its containing directory",
766                    current.display()
767                )));
768            }
769            if !fs::metadata(&current)?.is_dir() {
770                return Err(io::Error::other(format!("path component {} is not a directory", current.display())));
771            }
772        } else if !is_leaf && !metadata.is_dir() {
773            return Err(io::Error::other(format!("path component {} is not a directory", current.display())));
774        }
775    }
776    Ok(())
777}
778
779fn unique_private_file(parent: &Path, stem: &OsStr) -> Result<(PathBuf, File)> {
780    let timestamp = SystemTime::now()
781        .duration_since(UNIX_EPOCH)
782        .map(|duration| duration.as_nanos())
783        .unwrap_or_default();
784    let stem = stem.to_string_lossy();
785    for attempt in 0..32u8 {
786        let temporary = parent.join(format!(".{stem}.{}.{}.{}.tmp", std::process::id(), timestamp, attempt));
787        match create_private_new_file(&temporary) {
788            Ok(file) => return Ok((temporary, file)),
789            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
790            Err(error) => return Err(error).with_context(|| format!("could not create {}", temporary.display())),
791        }
792    }
793    bail!("could not allocate a unique private temporary file in {}", parent.display())
794}
795
796fn remove_temporary_file(path: &Path) {
797    if let Err(error) = fs::remove_file(path)
798        && error.kind() != io::ErrorKind::NotFound
799    {
800        tracing::debug!(path = %path.display(), %error, "failed to remove private temporary file");
801    }
802}
803fn set_private_permissions(path: &Path) -> io::Result<()> {
804    #[cfg(unix)]
805    {
806        use std::os::unix::fs::PermissionsExt;
807        fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
808    }
809    Ok(())
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815    use tempfile::tempdir;
816
817    fn migration_paths(temp: &tempfile::TempDir) -> VtCodePaths {
818        VtCodePaths {
819            config_dir: temp.path().join("config"),
820            data_dir: temp.path().join("data"),
821            state_dir: temp.path().join("state"),
822            cache_dir: temp.path().join("cache"),
823            runtime_dir: temp.path().join("runtime"),
824            executable_dir: temp.path().join("bin"),
825            system_config_dirs: Vec::new(),
826            system_data_dirs: Vec::new(),
827            legacy_home_dir: temp.path().join("legacy"),
828        }
829    }
830
831    #[test]
832    fn resolver_honors_explicit_overrides() {
833        let paths = VtCodePaths::from_environment(&[
834            ("VTCODE_HOME", "/ignored"),
835            ("VTCODE_CONFIG", "/config"),
836            ("VTCODE_DATA", "/data"),
837        ])
838        .expect("resolve paths");
839        assert_eq!(paths.config_dir(), Path::new("/config"));
840        assert_eq!(paths.data_dir(), Path::new("/data"));
841        assert_eq!(paths.legacy_home_dir(), Path::new("/ignored"));
842        assert_ne!(paths.auth_dir(), Path::new("/data/auth"));
843    }
844
845    #[test]
846    fn resolver_defaults_are_absolute_and_categories_are_separate() {
847        let paths = VtCodePaths::from_environment(&[]).expect("resolve defaults");
848        assert!(paths.config_dir().is_absolute());
849        assert!(paths.data_dir().is_absolute());
850        assert!(paths.runtime_dir().is_absolute());
851        assert_eq!(paths.auth_file(), paths.auth_dir().join("auth.json"));
852    }
853
854    #[cfg(any(
855        target_os = "linux",
856        target_os = "freebsd",
857        target_os = "netbsd",
858        target_os = "openbsd",
859        target_os = "dragonfly"
860    ))]
861    #[test]
862    fn resolver_ignores_relative_xdg_inputs() {
863        let paths = VtCodePaths::from_environment(&[
864            ("HOME", "/tmp/vtcode-home"),
865            ("XDG_CONFIG_HOME", "relative/config"),
866            ("XDG_RUNTIME_DIR", "relative/runtime"),
867            ("XDG_BIN_HOME", "relative/bin"),
868        ])
869        .expect("relative XDG root should be ignored");
870        assert_eq!(paths.config_dir(), Path::new("/tmp/vtcode-home/.config/vtcode"));
871        assert_eq!(paths.runtime_dir(), Path::new("/tmp/vtcode-home/.local/state/vtcode/runtime"));
872        assert_eq!(paths.executable_dir(), Path::new("/tmp/vtcode-home/.local/bin"));
873    }
874
875    #[cfg(any(
876        target_os = "linux",
877        target_os = "freebsd",
878        target_os = "netbsd",
879        target_os = "openbsd",
880        target_os = "dragonfly"
881    ))]
882    #[test]
883    fn resolver_preserves_xdg_search_order_and_defaults_empty_values() {
884        let paths = VtCodePaths::from_environment(&[
885            ("HOME", "/tmp/vtcode-home"),
886            ("XDG_CONFIG_DIRS", "/first:/second"),
887            ("XDG_DATA_DIRS", " "),
888        ])
889        .expect("resolve search roots");
890        assert_eq!(paths.system_config_dirs(), &[PathBuf::from("/first"), PathBuf::from("/second")]);
891        assert_eq!(paths.system_data_dirs(), &[PathBuf::from("/usr/local/share"), PathBuf::from("/usr/share")]);
892    }
893
894    #[cfg(any(
895        target_os = "linux",
896        target_os = "freebsd",
897        target_os = "netbsd",
898        target_os = "openbsd",
899        target_os = "dragonfly"
900    ))]
901    #[test]
902    fn system_config_paths_convert_xdg_preference_order_to_layer_order() {
903        let paths =
904            VtCodePaths::from_environment(&[("HOME", "/tmp/vtcode-home"), ("XDG_CONFIG_DIRS", "/first:/second")])
905                .expect("resolve search roots");
906
907        assert_eq!(
908            paths.system_config_paths("vtcode.toml").expect("resolve system config paths"),
909            vec![
910                PathBuf::from("/etc/vtcode/vtcode.toml"),
911                PathBuf::from("/second/vtcode/vtcode.toml"),
912                PathBuf::from("/first/vtcode/vtcode.toml"),
913            ]
914        );
915    }
916
917    #[cfg(target_os = "macos")]
918    #[test]
919    fn native_macos_resolution_ignores_xdg_roots() {
920        let paths = VtCodePaths::from_environment(&[
921            ("HOME", "/tmp/vtcode-home"),
922            ("XDG_CONFIG_HOME", "/tmp/xdg/config"),
923            ("XDG_DATA_HOME", "/tmp/xdg/data"),
924            ("XDG_STATE_HOME", "/tmp/xdg/state"),
925            ("XDG_CACHE_HOME", "/tmp/xdg/cache"),
926        ])
927        .expect("resolve native macOS paths");
928        assert!(!paths.config_dir().starts_with("/tmp/xdg"));
929        assert!(!paths.data_dir().starts_with("/tmp/xdg"));
930        assert!(paths.config_dir().to_string_lossy().contains("com.vinhnx.vtcode"));
931    }
932
933    #[cfg(target_os = "windows")]
934    #[test]
935    fn native_windows_resolution_ignores_xdg_roots() {
936        let paths = VtCodePaths::from_environment(&[
937            ("HOME", r"C:\\Users\\vtcode"),
938            ("XDG_CONFIG_HOME", r"C:\\xdg\\config"),
939            ("XDG_DATA_HOME", r"C:\\xdg\\data"),
940            ("XDG_STATE_HOME", r"C:\\xdg\\state"),
941            ("XDG_CACHE_HOME", r"C:\\xdg\\cache"),
942        ])
943        .expect("resolve native Windows paths");
944        assert!(!paths.config_dir().to_string_lossy().contains("xdg"));
945        assert!(!paths.data_dir().to_string_lossy().contains("xdg"));
946    }
947
948    #[cfg(unix)]
949    #[test]
950    fn runtime_and_auth_storage_are_private() {
951        use std::os::unix::fs::PermissionsExt;
952
953        let temp = tempdir().expect("tempdir");
954        let paths = migration_paths(&temp);
955        let runtime = paths.ensure_runtime_dir().expect("create runtime");
956        let auth = paths.ensure_auth_dir().expect("create auth");
957        let auth_file = paths.create_auth_file("credentials.json").expect("create auth file");
958
959        assert_eq!(fs::metadata(runtime).expect("runtime metadata").permissions().mode() & 0o777, 0o700);
960        assert_eq!(fs::metadata(auth).expect("auth metadata").permissions().mode() & 0o777, 0o700);
961        assert_eq!(fs::metadata(auth_file).expect("auth file metadata").permissions().mode() & 0o777, 0o600);
962    }
963
964    #[cfg(unix)]
965    #[test]
966    fn newly_created_user_directories_are_private_but_existing_modes_are_preserved() {
967        use std::os::unix::fs::{PermissionsExt, symlink};
968
969        let temp = tempdir().expect("tempdir");
970        let paths = migration_paths(&temp);
971        fs::create_dir_all(paths.config_dir()).expect("existing config directory");
972        fs::set_permissions(paths.config_dir(), fs::Permissions::from_mode(0o755)).expect("set existing mode");
973
974        let _ = paths.ensure_config_dir().expect("preserve existing config directory");
975        let _ = paths.ensure_data_dir().expect("create data directory");
976        let _ = paths.ensure_state_child_dir("sessions").expect("create state child");
977        let _ = paths.ensure_cache_child_dir("prompts").expect("create cache child");
978        let _ = paths.ensure_executable_dir().expect("create executable directory");
979
980        assert_eq!(fs::metadata(paths.config_dir()).expect("config metadata").permissions().mode() & 0o777, 0o755);
981        assert_eq!(fs::metadata(paths.data_dir()).expect("data metadata").permissions().mode() & 0o777, 0o700);
982        assert_eq!(
983            fs::metadata(paths.state_dir().join("sessions"))
984                .expect("state child metadata")
985                .permissions()
986                .mode()
987                & 0o777,
988            0o700
989        );
990        assert_eq!(
991            fs::metadata(paths.cache_dir().join("prompts"))
992                .expect("cache child metadata")
993                .permissions()
994                .mode()
995                & 0o777,
996            0o700
997        );
998        assert_eq!(
999            fs::metadata(paths.executable_dir())
1000                .expect("executable metadata")
1001                .permissions()
1002                .mode()
1003                & 0o777,
1004            0o700
1005        );
1006
1007        symlink(temp.path().join("outside"), paths.cache_dir().join("unsafe")).expect("create cache symlink");
1008        assert!(paths.ensure_cache_child_dir("unsafe/nested").is_err());
1009    }
1010
1011    #[test]
1012    fn migration_copies_explicit_categories_once_and_preserves_sources() {
1013        let temp = tempdir().expect("tempdir");
1014        let paths = migration_paths(&temp);
1015        fs::create_dir_all(paths.legacy_home_dir().join("plugins")).expect("legacy plugins");
1016        fs::write(paths.legacy_home_dir().join("vtcode.toml"), "theme = 'dark'").expect("legacy config");
1017        fs::write(paths.legacy_home_dir().join("plugins/example"), "plugin").expect("legacy plugin");
1018
1019        let first = paths.migrate_legacy().expect("migrate legacy data");
1020        let second = paths.migrate_legacy().expect("migrate idempotently");
1021
1022        assert_eq!(fs::read_to_string(paths.config_file()).expect("migrated config"), "theme = 'dark'");
1023        assert_eq!(fs::read_to_string(paths.plugins_dir().join("example")).expect("migrated plugin"), "plugin");
1024        assert!(paths.legacy_home_dir().join("vtcode.toml").exists());
1025        assert_eq!(first.migrated.len(), 2);
1026        assert!(first.marker_written);
1027        assert!(paths.migration_report_path().is_file());
1028        assert!(second.already_completed);
1029    }
1030
1031    #[test]
1032    fn migration_copies_user_guidance_and_prompt_configuration_to_config() {
1033        let temp = tempdir().expect("tempdir");
1034        let paths = migration_paths(&temp);
1035        fs::create_dir_all(paths.legacy_home_dir().join("prompts/examples")).expect("legacy prompts");
1036        fs::write(paths.legacy_home_dir().join("AGENTS.md"), "user guidance").expect("legacy guidance");
1037        fs::write(paths.legacy_home_dir().join("config.toml"), "enabled = true").expect("legacy dot config");
1038        fs::write(paths.legacy_home_dir().join("prompts/examples/example.md"), "# Example")
1039            .expect("legacy prompt example");
1040
1041        let report = paths.migrate_legacy().expect("migrate user configuration");
1042
1043        assert_eq!(
1044            fs::read_to_string(paths.config_dir().join("AGENTS.md")).expect("migrated guidance"),
1045            "user guidance"
1046        );
1047        assert_eq!(
1048            fs::read_to_string(paths.config_dir().join("config.toml")).expect("migrated dot config"),
1049            "enabled = true"
1050        );
1051        assert_eq!(
1052            fs::read_to_string(paths.config_dir().join("prompts/examples/example.md")).expect("migrated prompt"),
1053            "# Example"
1054        );
1055        assert!(report.migrated.len() >= 3);
1056        assert!(paths.legacy_home_dir().join("prompts/examples/example.md").is_file());
1057    }
1058
1059    #[test]
1060    fn migration_reports_conflicts_and_excludes_tmp() {
1061        let temp = tempdir().expect("tempdir");
1062        let paths = migration_paths(&temp);
1063        fs::create_dir_all(paths.legacy_home_dir()).expect("legacy root");
1064        fs::write(paths.legacy_home_dir().join("vtcode.toml"), "legacy").expect("legacy config");
1065        fs::write(paths.legacy_home_dir().join("tmp"), "temporary").expect("legacy temporary file");
1066        fs::create_dir_all(paths.config_dir()).expect("config root");
1067        fs::write(paths.config_file(), "current").expect("current config");
1068
1069        let report = paths.migrate_legacy().expect("migrate with conflict");
1070
1071        assert_eq!(fs::read_to_string(paths.config_file()).expect("current config"), "current");
1072        assert!(
1073            report
1074                .skipped
1075                .iter()
1076                .any(|skip| skip.reason == MigrationSkipReason::DestinationExists)
1077        );
1078        assert!(report.skipped.iter().any(|skip| skip.reason == MigrationSkipReason::Excluded));
1079        assert!(!paths.runtime_dir().join("tmp").exists());
1080    }
1081
1082    #[test]
1083    fn migration_does_not_trust_legacy_migration_metadata() {
1084        let temp = tempdir().expect("tempdir");
1085        let paths = migration_paths(&temp);
1086        let legacy_migration = paths.legacy_home_dir().join("state/migration");
1087        fs::create_dir_all(&legacy_migration).expect("legacy migration directory");
1088        fs::write(legacy_migration.join("legacy-v1.complete"), "spoofed\n").expect("spoofed marker");
1089
1090        let report = paths.migrate_legacy().expect("migrate legacy metadata");
1091
1092        assert!(report.marker_written);
1093        assert_eq!(
1094            fs::read_to_string(paths.migration_marker_path()).expect("current migration marker"),
1095            "legacy migration completed\n"
1096        );
1097        assert!(
1098            !report
1099                .migrated
1100                .iter()
1101                .any(|entry| entry.destination == paths.migration_marker_path())
1102        );
1103        assert!(report.skipped.iter().any(|skip| {
1104            skip.path == paths.legacy_home_dir().join("state/migration") && skip.reason == MigrationSkipReason::Excluded
1105        }));
1106    }
1107
1108    #[cfg(unix)]
1109    #[test]
1110    fn private_file_writer_rejects_symlink_escape_and_final_symlink() {
1111        use std::os::unix::fs::symlink;
1112
1113        let temp = tempdir().expect("tempdir");
1114        let outside = temp.path().join("outside");
1115        fs::create_dir_all(&outside).expect("outside directory");
1116        let escaped_parent = temp.path().join("escaped");
1117        symlink(&outside, &escaped_parent).expect("escape symlink");
1118
1119        assert!(VtCodePaths::write_private_file_atomic(escaped_parent.join("data"), b"blocked").is_err());
1120        assert!(!outside.join("data").exists());
1121
1122        let safe_parent = temp.path().join("safe");
1123        fs::create_dir_all(&safe_parent).expect("safe directory");
1124        let destination = safe_parent.join("data");
1125        fs::write(&destination, "original").expect("destination");
1126        let linked = safe_parent.join("linked");
1127        symlink(&destination, &linked).expect("final symlink");
1128        assert!(VtCodePaths::write_private_file_atomic(&linked, b"blocked").is_err());
1129        assert_eq!(fs::read_to_string(destination).expect("original data"), "original");
1130    }
1131
1132    #[cfg(unix)]
1133    #[test]
1134    fn migration_skips_symlinks_and_special_files_without_traversing_them() {
1135        use std::os::unix::fs::symlink;
1136
1137        let temp = tempdir().expect("tempdir");
1138        let paths = migration_paths(&temp);
1139        let outside = temp.path().join("outside");
1140        fs::create_dir_all(&outside).expect("outside root");
1141        fs::write(outside.join("secret"), "secret").expect("outside secret");
1142        fs::create_dir_all(paths.legacy_home_dir().join("plugins")).expect("legacy plugins");
1143        symlink(&outside, paths.legacy_home_dir().join("plugins/link")).expect("legacy symlink");
1144        let socket = paths.legacy_home_dir().join("plugins/socket");
1145        let _listener = std::os::unix::net::UnixListener::bind(&socket).expect("create unix socket");
1146
1147        let report = paths.migrate_legacy().expect("migrate safely");
1148
1149        assert!(report.skipped.iter().any(|skip| skip.reason == MigrationSkipReason::Symlink));
1150        assert!(
1151            report
1152                .skipped
1153                .iter()
1154                .any(|skip| skip.reason == MigrationSkipReason::SpecialFile)
1155        );
1156        assert!(!paths.plugins_dir().join("link/secret").exists());
1157    }
1158
1159    #[test]
1160    fn migration_retries_destination_failures_before_writing_marker() {
1161        let temp = tempdir().expect("tempdir");
1162        let paths = migration_paths(&temp);
1163        fs::create_dir_all(paths.legacy_home_dir()).expect("legacy root");
1164        fs::write(paths.legacy_home_dir().join("vtcode.toml"), "legacy").expect("legacy config");
1165        fs::write(paths.config_dir(), "unsafe root").expect("unsafe config root");
1166
1167        let first_report = paths.migrate_legacy().expect("migration report");
1168
1169        assert!(!first_report.failures.is_empty());
1170        assert!(!first_report.marker_written);
1171
1172        fs::remove_file(paths.config_dir()).expect("remove blocked config root");
1173        let second_report = paths.migrate_legacy().expect("retry migration");
1174
1175        assert!(second_report.marker_written);
1176        assert_eq!(fs::read_to_string(paths.config_file()).expect("migrated config"), "legacy");
1177    }
1178}