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