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