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            // rename replaces existing files on Windows too. Removing the
424            // destination first would lose the last committed file on failure.
425            fs::rename(&temporary, destination)
426        })();
427        if result.is_err() {
428            remove_temporary_file(&temporary);
429        }
430        result.with_context(|| format!("could not atomically write {}", destination.display()))
431    }
432
433    /// Atomically publishes a private file only when the destination is absent.
434    ///
435    /// The temporary file is linked into place instead of renamed over the
436    /// destination. This makes legacy-cache republishing safe when multiple
437    /// VT Code processes initialize the same cache concurrently: the first
438    /// publisher wins and a newer canonical cache cannot be clobbered.
439    pub fn write_private_file_atomic_if_absent(path: impl AsRef<Path>, contents: &[u8]) -> Result<bool> {
440        let destination = path.as_ref();
441        ensure_file_parent(destination)?;
442        validate_file_destination(destination)?;
443        if fs::symlink_metadata(destination).is_ok() {
444            return Ok(false);
445        }
446
447        let parent = destination
448            .parent()
449            .ok_or_else(|| anyhow!("private file {} has no parent", destination.display()))?;
450        let stem = destination.file_name().unwrap_or_else(|| OsStr::new("file"));
451        let (temporary, mut file) = unique_private_file(parent, stem)?;
452        let result: io::Result<bool> = (|| {
453            file.write_all(contents)?;
454            file.sync_all()?;
455            drop(file);
456            match fs::hard_link(&temporary, destination) {
457                Ok(()) => Ok(true),
458                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(false),
459                Err(error) => Err(error),
460            }
461        })();
462        remove_temporary_file(&temporary);
463        result.with_context(|| format!("could not atomically create {}", destination.display()))
464    }
465
466    /// Runs an operation while holding an exclusive private lock adjacent to a file.
467    ///
468    /// Lock acquisition is bounded and released by the operating system when
469    /// the owning process exits, so an abandoned lock cannot permanently
470    /// disable cache writes.
471    pub fn with_private_file_lock<T>(path: impl AsRef<Path>, operation: impl FnOnce() -> Result<T>) -> Result<T> {
472        let destination = path.as_ref();
473        ensure_file_parent(destination)?;
474        let parent = destination
475            .parent()
476            .ok_or_else(|| anyhow!("private file {} has no parent", destination.display()))?;
477        let stem = destination.file_name().unwrap_or_else(|| OsStr::new("file"));
478        let lock_path = parent.join(format!(".{}.lock", stem.to_string_lossy()));
479        let _lock = acquire_private_file_lock(&lock_path)?;
480        operation()
481    }
482
483    /// Creates a private runtime child directory after validating its path.
484    pub fn ensure_runtime_child_dir(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
485        let path = self.runtime_path(relative)?;
486        ensure_private_dir(&path).context("could not create VT Code runtime child directory")?;
487        Ok(path)
488    }
489
490    /// Creates a user configuration child directory after validating its path.
491    pub fn ensure_config_child_dir(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
492        let path = self.config_path(relative)?;
493        ensure_user_dir(&path).context("could not create VT Code configuration child directory")?;
494        Ok(path)
495    }
496
497    /// Creates a user data child directory after validating its path.
498    pub fn ensure_data_child_dir(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
499        let path = self.data_path(relative)?;
500        ensure_user_dir(&path).context("could not create VT Code data child directory")?;
501        Ok(path)
502    }
503
504    /// Creates a user state child directory after validating its path.
505    pub fn ensure_state_child_dir(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
506        let path = self.state_path(relative)?;
507        ensure_user_dir(&path).context("could not create VT Code state child directory")?;
508        Ok(path)
509    }
510
511    /// Creates a user cache child directory after validating its path.
512    pub fn ensure_cache_child_dir(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
513        let path = self.cache_path(relative)?;
514        ensure_user_dir(&path).context("could not create VT Code cache child directory")?;
515        Ok(path)
516    }
517
518    /// Creates a managed executable child directory after validating its path.
519    pub fn ensure_executable_child_dir(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
520        let path = self.executable_path(relative)?;
521        ensure_user_dir(&path).context("could not create VT Code executable child directory")?;
522        Ok(path)
523    }
524
525    /// Creates private auth storage and returns its path.
526    pub fn ensure_auth_dir(&self) -> Result<PathBuf> {
527        let path = self.auth_dir();
528        ensure_private_dir(&path).context("could not create VT Code authentication directory")?;
529        Ok(path)
530    }
531
532    /// Creates an empty, private auth file. The name must not contain a path.
533    pub fn create_auth_file(&self, name: impl AsRef<str>) -> Result<PathBuf> {
534        let name = name.as_ref();
535        if !is_safe_file_name(name) {
536            bail!("authentication file name '{name}' must be one normal path component");
537        }
538        let path = self.ensure_auth_dir()?.join(name);
539        let _file = create_private_new_file(&path)
540            .with_context(|| format!("could not create authentication file {}", path.display()))?;
541        Ok(path)
542    }
543
544    /// Copies eligible legacy global data without modifying its source.
545    pub fn migrate_legacy(&self) -> Result<MigrationReport> {
546        LegacyMigrator::new(self.clone()).run()
547    }
548}
549
550fn env_path(environment: &BTreeMap<OsString, OsString>, name: &str) -> Option<PathBuf> {
551    environment
552        .get(OsStr::new(name))
553        .filter(|value| !value.is_empty())
554        .and_then(|value| {
555            if let Some(text) = value.to_str() {
556                let trimmed = text.trim();
557                (!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
558            } else {
559                Some(PathBuf::from(value))
560            }
561        })
562}
563fn xdg_app_dir(environment: &BTreeMap<OsString, OsString>, name: &str, native: &Path) -> Result<PathBuf> {
564    if is_xdg_platform()
565        && let Some(path) = env_path(environment, name)
566        && path.is_absolute()
567    {
568        return Ok(path.join(APP));
569    }
570    Ok(native.to_path_buf())
571}
572fn executable_dir(environment: &BTreeMap<OsString, OsString>, native: PathBuf) -> Result<PathBuf> {
573    if is_xdg_platform()
574        && let Some(path) = env_path(environment, "XDG_BIN_HOME")
575        && path.is_absolute()
576    {
577        return Ok(path);
578    }
579    Ok(native)
580}
581fn system_config_dirs(environment: &BTreeMap<OsString, OsString>) -> Result<Vec<PathBuf>> {
582    if !is_xdg_platform() {
583        return Ok(Vec::new());
584    }
585    let configured = environment.get(OsStr::new("XDG_CONFIG_DIRS")).map(OsString::as_os_str);
586    let mut paths = configured
587        .into_iter()
588        .flat_map(std::env::split_paths)
589        .filter(|path| path.is_absolute())
590        .collect::<Vec<_>>();
591    if paths.is_empty() {
592        paths.push(PathBuf::from("/etc/xdg"));
593    }
594    Ok(paths)
595}
596fn system_data_dirs(environment: &BTreeMap<OsString, OsString>) -> Result<Vec<PathBuf>> {
597    if !is_xdg_platform() {
598        return Ok(Vec::new());
599    }
600    let configured = environment.get(OsStr::new("XDG_DATA_DIRS")).map(OsString::as_os_str);
601    let mut paths = configured
602        .into_iter()
603        .flat_map(std::env::split_paths)
604        .filter(|path| path.is_absolute())
605        .collect::<Vec<_>>();
606    if paths.is_empty() {
607        paths.extend([PathBuf::from("/usr/local/share"), PathBuf::from("/usr/share")]);
608    }
609    Ok(paths)
610}
611fn validate_absolute(name: &str, path: &Path) -> Result<()> {
612    if path.is_absolute() {
613        Ok(())
614    } else {
615        bail!("{name} must be an absolute path, got '{}'", path.display())
616    }
617}
618fn validate_relative_path(name: &str, path: &Path) -> Result<()> {
619    if !path.as_os_str().is_empty() && path.components().all(|component| matches!(component, Component::Normal(_))) {
620        Ok(())
621    } else {
622        bail!("{name} must be a non-empty relative path without traversal, got '{}'", path.display())
623    }
624}
625
626fn child_path(root: &Path, relative: &Path, category: &str) -> Result<PathBuf> {
627    validate_relative_path(&format!("{category} child path"), relative)?;
628    Ok(root.join(relative))
629}
630const fn is_xdg_platform() -> bool {
631    cfg!(any(
632        target_os = "linux",
633        target_os = "freebsd",
634        target_os = "netbsd",
635        target_os = "openbsd",
636        target_os = "dragonfly"
637    ))
638}
639fn is_safe_file_name(name: &str) -> bool {
640    let mut parts = Path::new(name).components();
641    matches!(parts.next(), Some(Component::Normal(_))) && parts.next().is_none()
642}
643fn ensure_private_dir(path: &Path) -> io::Result<()> {
644    match fs::symlink_metadata(path) {
645        Ok(metadata) if metadata.file_type().is_symlink() => {
646            return Err(io::Error::other(format!("refusing symlink directory {}", path.display())));
647        }
648        Ok(metadata) if !metadata.is_dir() => {
649            return Err(io::Error::other(format!("{} is not a directory", path.display())));
650        }
651        Ok(_) => {}
652        Err(error) if error.kind() == io::ErrorKind::NotFound => {
653            ensure_private_parent_dir(path)?;
654            create_private_dir(path)?;
655        }
656        Err(error) => return Err(error),
657    }
658    set_private_permissions(path)
659}
660
661/// Ensure a user-owned directory exists without changing the mode of any
662/// directory that was already present. Every directory created by this
663/// helper is private on Unix.
664fn ensure_user_dir(path: &Path) -> io::Result<()> {
665    validate_no_escaping_symlink_ancestors(path, true)?;
666    match fs::symlink_metadata(path) {
667        Ok(metadata) if metadata.file_type().is_symlink() => {
668            return Err(io::Error::other(format!("refusing symlink directory {}", path.display())));
669        }
670        Ok(metadata) if !metadata.is_dir() => {
671            return Err(io::Error::other(format!("{} is not a directory", path.display())));
672        }
673        Ok(_) => return Ok(()),
674        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
675        Err(error) => return Err(error),
676    }
677
678    let Some(parent) = path.parent() else {
679        return Err(io::Error::other(format!("{} has no parent directory", path.display())));
680    };
681    if parent != path {
682        ensure_user_dir(parent)?;
683    }
684    match fs::symlink_metadata(path) {
685        Ok(metadata) if metadata.file_type().is_symlink() => {
686            Err(io::Error::other(format!("refusing symlink directory {}", path.display())))
687        }
688        Ok(metadata) if !metadata.is_dir() => Err(io::Error::other(format!("{} is not a directory", path.display()))),
689        Ok(_) => Ok(()),
690        Err(error) if error.kind() == io::ErrorKind::NotFound => create_user_dir(path),
691        Err(error) => Err(error),
692    }
693}
694
695fn ensure_private_parent_dir(path: &Path) -> io::Result<()> {
696    validate_no_escaping_symlink_ancestors(path, true)?;
697    let Some(parent) = path.parent() else {
698        return Ok(());
699    };
700    if parent == path {
701        return Ok(());
702    }
703    match fs::symlink_metadata(parent) {
704        Ok(metadata) if metadata.file_type().is_symlink() => {
705            Err(io::Error::other(format!("refusing symlink directory {}", parent.display())))
706        }
707        Ok(metadata) if !metadata.is_dir() => Err(io::Error::other(format!("{} is not a directory", parent.display()))),
708        Ok(_) => Ok(()),
709        Err(error) if error.kind() == io::ErrorKind::NotFound => {
710            ensure_private_parent_dir(parent)?;
711            create_user_dir(parent)
712        }
713        Err(error) => Err(error),
714    }
715}
716
717/// Ensure migration-created directories are private while preserving the mode
718/// of directories that already existed at the destination.
719fn ensure_migration_dir(path: &Path) -> io::Result<()> {
720    ensure_user_dir(path)
721}
722
723fn create_user_dir(path: &Path) -> io::Result<()> {
724    match fs::create_dir(path) {
725        Ok(()) => set_private_permissions(path),
726        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => match fs::symlink_metadata(path) {
727            Ok(metadata) if metadata.file_type().is_symlink() => {
728                Err(io::Error::other(format!("refusing symlink directory {}", path.display())))
729            }
730            Ok(metadata) if !metadata.is_dir() => {
731                Err(io::Error::other(format!("{} is not a directory", path.display())))
732            }
733            Ok(_) => Ok(()),
734            Err(error) => Err(error),
735        },
736        Err(error) => Err(error),
737    }
738}
739
740fn create_private_dir(path: &Path) -> io::Result<()> {
741    match fs::create_dir(path) {
742        Ok(()) => Ok(()),
743        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => match fs::symlink_metadata(path) {
744            Ok(metadata) if metadata.file_type().is_symlink() => {
745                Err(io::Error::other(format!("refusing symlink directory {}", path.display())))
746            }
747            Ok(metadata) if !metadata.is_dir() => {
748                Err(io::Error::other(format!("{} is not a directory", path.display())))
749            }
750            Ok(_) => Ok(()),
751            Err(error) => Err(error),
752        },
753        Err(error) => Err(error),
754    }
755}
756
757fn create_private_new_file(path: &Path) -> io::Result<File> {
758    let mut options = OpenOptions::new();
759    let _ = options.write(true).create_new(true);
760    #[cfg(unix)]
761    {
762        use std::os::unix::fs::OpenOptionsExt;
763        let _ = options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
764    }
765    options.open(path)
766}
767fn open_no_follow(path: &Path) -> io::Result<File> {
768    let mut options = OpenOptions::new();
769    let _ = options.read(true);
770    #[cfg(unix)]
771    {
772        use std::os::unix::fs::OpenOptionsExt;
773        let _ = options.custom_flags(libc::O_NOFOLLOW);
774    }
775    options.open(path)
776}
777
778fn open_private_append(path: &Path) -> io::Result<File> {
779    let mut options = OpenOptions::new();
780    let _ = options.create(true).append(true).read(true).write(true);
781    #[cfg(unix)]
782    {
783        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
784        let _ = options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
785        let file = options.open(path)?;
786        file.set_permissions(fs::Permissions::from_mode(0o600))?;
787        Ok(file)
788    }
789    #[cfg(not(unix))]
790    options.open(path)
791}
792
793fn ensure_file_parent(path: &Path) -> Result<()> {
794    let parent = path
795        .parent()
796        .ok_or_else(|| anyhow!("file {} has no parent directory", path.display()))?;
797    let parent = if parent.as_os_str().is_empty() {
798        Path::new(".")
799    } else {
800        parent
801    };
802    ensure_user_dir(parent).with_context(|| format!("could not create file parent {}", parent.display()))?;
803    Ok(())
804}
805
806fn validate_file_destination(path: &Path) -> Result<()> {
807    match fs::symlink_metadata(path) {
808        Ok(metadata) if metadata.file_type().is_symlink() => {
809            bail!("refusing to replace symlinked file {}", path.display())
810        }
811        Ok(metadata) if !metadata.is_file() => bail!("{} is not a regular file", path.display()),
812        Ok(_) => Ok(()),
813        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
814        Err(error) => Err(error).with_context(|| format!("could not inspect {}", path.display())),
815    }
816}
817
818/// Validate every existing path component and reject escaping symlinks.
819///
820/// Platform aliases such as macOS `/tmp -> /private/tmp` are accepted when a
821/// symlink resolves beneath its containing directory. Missing trailing
822/// components are allowed so callers can create them safely afterwards.
823fn validate_no_escaping_symlink_ancestors(path: &Path, allow_missing_tail: bool) -> io::Result<()> {
824    let components = path.components().collect::<Vec<_>>();
825    let mut current = PathBuf::new();
826    for (index, component) in components.iter().enumerate() {
827        if matches!(component, Component::ParentDir) {
828            return Err(io::Error::other(format!("path contains traversal: {}", path.display())));
829        }
830        if matches!(component, Component::CurDir) {
831            continue;
832        }
833        current.push(component.as_os_str());
834        let is_leaf = index + 1 == components.len();
835        let metadata = match fs::symlink_metadata(&current) {
836            Ok(metadata) => metadata,
837            Err(error) if allow_missing_tail && error.kind() == io::ErrorKind::NotFound => break,
838            Err(error) => return Err(error),
839        };
840        if metadata.file_type().is_symlink() {
841            if is_leaf {
842                return Err(io::Error::other(format!("refusing symlink path {}", current.display())));
843            }
844            let parent = current
845                .parent()
846                .filter(|parent| !parent.as_os_str().is_empty())
847                .unwrap_or_else(|| Path::new("."));
848            let canonical_parent = crate::canonicalize(parent)?;
849            let canonical_target = crate::canonicalize(&current)?;
850            if !canonical_target.starts_with(&canonical_parent) {
851                return Err(io::Error::other(format!(
852                    "path component {} escapes its containing directory",
853                    current.display()
854                )));
855            }
856            if !fs::metadata(&current)?.is_dir() {
857                return Err(io::Error::other(format!("path component {} is not a directory", current.display())));
858            }
859        } else if !is_leaf && !metadata.is_dir() {
860            return Err(io::Error::other(format!("path component {} is not a directory", current.display())));
861        }
862    }
863    Ok(())
864}
865
866fn unique_private_file(parent: &Path, stem: &OsStr) -> Result<(PathBuf, File)> {
867    let timestamp = SystemTime::now()
868        .duration_since(UNIX_EPOCH)
869        .map(|duration| duration.as_nanos())
870        .unwrap_or_default();
871    let stem = stem.to_string_lossy();
872    for attempt in 0..32u8 {
873        let temporary = parent.join(format!(".{stem}.{}.{}.{}.tmp", std::process::id(), timestamp, attempt));
874        match create_private_new_file(&temporary) {
875            Ok(file) => return Ok((temporary, file)),
876            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
877            Err(error) => return Err(error).with_context(|| format!("could not create {}", temporary.display())),
878        }
879    }
880    bail!("could not allocate a unique private temporary file in {}", parent.display())
881}
882
883struct PrivateFileLock {
884    _file: File,
885}
886
887fn acquire_private_file_lock(path: &Path) -> Result<PrivateFileLock> {
888    let file = open_private_lock_file(path)?;
889    for attempt in 0..PRIVATE_FILE_LOCK_ATTEMPTS {
890        match file.try_lock_exclusive() {
891            Ok(()) => return Ok(PrivateFileLock { _file: file }),
892            Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
893                if attempt + 1 < PRIVATE_FILE_LOCK_ATTEMPTS {
894                    std::thread::sleep(PRIVATE_FILE_LOCK_DELAY);
895                }
896            }
897            Err(error) => return Err(error).with_context(|| format!("could not lock {}", path.display())),
898        }
899    }
900    bail!("timed out waiting for private file lock {}", path.display())
901}
902
903fn open_private_lock_file(path: &Path) -> Result<File> {
904    match create_private_lock_file(path) {
905        Ok(file) => Ok(file),
906        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
907            validate_file_destination(path)?;
908            let mut options = OpenOptions::new();
909            let _ = options.read(true).write(true);
910            #[cfg(unix)]
911            {
912                use std::os::unix::fs::OpenOptionsExt;
913                let _ = options.custom_flags(libc::O_NOFOLLOW);
914            }
915            options
916                .open(path)
917                .with_context(|| format!("could not open private lock {}", path.display()))
918        }
919        Err(error) => Err(error).with_context(|| format!("could not create lock {}", path.display())),
920    }
921}
922
923fn create_private_lock_file(path: &Path) -> io::Result<File> {
924    let mut options = OpenOptions::new();
925    let _ = options.read(true).write(true).create_new(true);
926    #[cfg(unix)]
927    {
928        use std::os::unix::fs::OpenOptionsExt;
929        let _ = options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
930    }
931    options.open(path)
932}
933
934impl Drop for PrivateFileLock {
935    fn drop(&mut self) {
936        if let Err(error) = self._file.unlock() {
937            tracing::debug!(%error, "failed to release private file lock");
938        }
939    }
940}
941
942fn remove_temporary_file(path: &Path) {
943    if let Err(error) = fs::remove_file(path)
944        && error.kind() != io::ErrorKind::NotFound
945    {
946        tracing::debug!(path = %path.display(), %error, "failed to remove private temporary file");
947    }
948}
949fn set_private_permissions(path: &Path) -> io::Result<()> {
950    #[cfg(unix)]
951    {
952        use std::os::unix::fs::PermissionsExt;
953        fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
954    }
955    Ok(())
956}
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961    use tempfile::tempdir;
962
963    fn migration_paths(temp: &tempfile::TempDir) -> VtCodePaths {
964        VtCodePaths {
965            config_dir: temp.path().join("config"),
966            data_dir: temp.path().join("data"),
967            state_dir: temp.path().join("state"),
968            cache_dir: temp.path().join("cache"),
969            runtime_dir: temp.path().join("runtime"),
970            executable_dir: temp.path().join("bin"),
971            system_config_dirs: Vec::new(),
972            system_data_dirs: Vec::new(),
973            legacy_home_dir: temp.path().join("legacy"),
974        }
975    }
976
977    #[test]
978    fn resolver_honors_explicit_overrides() {
979        let paths = VtCodePaths::from_environment(&[
980            ("VTCODE_HOME", "/ignored"),
981            ("VTCODE_CONFIG", "/config"),
982            ("VTCODE_DATA", "/data"),
983        ])
984        .expect("resolve paths");
985        assert_eq!(paths.config_dir(), Path::new("/config"));
986        assert_eq!(paths.data_dir(), Path::new("/data"));
987        assert_eq!(paths.legacy_home_dir(), Path::new("/ignored"));
988        assert_ne!(paths.auth_dir(), Path::new("/data/auth"));
989    }
990
991    #[test]
992    fn resolver_defaults_are_absolute_and_categories_are_separate() {
993        let paths = VtCodePaths::from_environment(&[]).expect("resolve defaults");
994        assert!(paths.config_dir().is_absolute());
995        assert!(paths.data_dir().is_absolute());
996        assert!(paths.runtime_dir().is_absolute());
997        assert_eq!(paths.auth_file(), paths.auth_dir().join("auth.json"));
998    }
999
1000    #[cfg(any(
1001        target_os = "linux",
1002        target_os = "freebsd",
1003        target_os = "netbsd",
1004        target_os = "openbsd",
1005        target_os = "dragonfly"
1006    ))]
1007    #[test]
1008    fn resolver_ignores_relative_xdg_inputs() {
1009        let paths = VtCodePaths::from_environment(&[
1010            ("HOME", "/tmp/vtcode-home"),
1011            ("XDG_CONFIG_HOME", "relative/config"),
1012            ("XDG_RUNTIME_DIR", "relative/runtime"),
1013            ("XDG_BIN_HOME", "relative/bin"),
1014        ])
1015        .expect("relative XDG root should be ignored");
1016        assert_eq!(paths.config_dir(), Path::new("/tmp/vtcode-home/.config/vtcode"));
1017        assert_eq!(paths.runtime_dir(), Path::new("/tmp/vtcode-home/.local/state/vtcode/runtime"));
1018        assert_eq!(paths.executable_dir(), Path::new("/tmp/vtcode-home/.local/bin"));
1019    }
1020
1021    #[cfg(any(
1022        target_os = "linux",
1023        target_os = "freebsd",
1024        target_os = "netbsd",
1025        target_os = "openbsd",
1026        target_os = "dragonfly"
1027    ))]
1028    #[test]
1029    fn resolver_preserves_xdg_search_order_and_defaults_empty_values() {
1030        let paths = VtCodePaths::from_environment(&[
1031            ("HOME", "/tmp/vtcode-home"),
1032            ("XDG_CONFIG_DIRS", "/first:/second"),
1033            ("XDG_DATA_DIRS", " "),
1034        ])
1035        .expect("resolve search roots");
1036        assert_eq!(paths.system_config_dirs(), &[PathBuf::from("/first"), PathBuf::from("/second")]);
1037        assert_eq!(paths.system_data_dirs(), &[PathBuf::from("/usr/local/share"), PathBuf::from("/usr/share")]);
1038    }
1039
1040    #[cfg(any(
1041        target_os = "linux",
1042        target_os = "freebsd",
1043        target_os = "netbsd",
1044        target_os = "openbsd",
1045        target_os = "dragonfly"
1046    ))]
1047    #[test]
1048    fn system_config_paths_convert_xdg_preference_order_to_layer_order() {
1049        let paths =
1050            VtCodePaths::from_environment(&[("HOME", "/tmp/vtcode-home"), ("XDG_CONFIG_DIRS", "/first:/second")])
1051                .expect("resolve search roots");
1052
1053        assert_eq!(
1054            paths.system_config_paths("vtcode.toml").expect("resolve system config paths"),
1055            vec![
1056                PathBuf::from("/etc/vtcode/vtcode.toml"),
1057                PathBuf::from("/second/vtcode/vtcode.toml"),
1058                PathBuf::from("/first/vtcode/vtcode.toml"),
1059            ]
1060        );
1061    }
1062
1063    #[cfg(target_os = "macos")]
1064    #[test]
1065    fn native_macos_resolution_ignores_xdg_roots() {
1066        let paths = VtCodePaths::from_environment(&[
1067            ("HOME", "/tmp/vtcode-home"),
1068            ("XDG_CONFIG_HOME", "/tmp/xdg/config"),
1069            ("XDG_DATA_HOME", "/tmp/xdg/data"),
1070            ("XDG_STATE_HOME", "/tmp/xdg/state"),
1071            ("XDG_CACHE_HOME", "/tmp/xdg/cache"),
1072        ])
1073        .expect("resolve native macOS paths");
1074        assert!(!paths.config_dir().starts_with("/tmp/xdg"));
1075        assert!(!paths.data_dir().starts_with("/tmp/xdg"));
1076        assert!(paths.config_dir().to_string_lossy().contains("com.vinhnx.vtcode"));
1077    }
1078
1079    #[cfg(target_os = "windows")]
1080    #[test]
1081    fn native_windows_resolution_ignores_xdg_roots() {
1082        let paths = VtCodePaths::from_environment(&[
1083            ("HOME", r"C:\\Users\\vtcode"),
1084            ("XDG_CONFIG_HOME", r"C:\\xdg\\config"),
1085            ("XDG_DATA_HOME", r"C:\\xdg\\data"),
1086            ("XDG_STATE_HOME", r"C:\\xdg\\state"),
1087            ("XDG_CACHE_HOME", r"C:\\xdg\\cache"),
1088        ])
1089        .expect("resolve native Windows paths");
1090        assert!(!paths.config_dir().to_string_lossy().contains("xdg"));
1091        assert!(!paths.data_dir().to_string_lossy().contains("xdg"));
1092    }
1093
1094    #[cfg(unix)]
1095    #[test]
1096    fn runtime_and_auth_storage_are_private() {
1097        use std::os::unix::fs::PermissionsExt;
1098
1099        let temp = tempdir().expect("tempdir");
1100        let paths = migration_paths(&temp);
1101        let runtime = paths.ensure_runtime_dir().expect("create runtime");
1102        let auth = paths.ensure_auth_dir().expect("create auth");
1103        let auth_file = paths.create_auth_file("credentials.json").expect("create auth file");
1104
1105        assert_eq!(fs::metadata(runtime).expect("runtime metadata").permissions().mode() & 0o777, 0o700);
1106        assert_eq!(fs::metadata(auth).expect("auth metadata").permissions().mode() & 0o777, 0o700);
1107        assert_eq!(fs::metadata(auth_file).expect("auth file metadata").permissions().mode() & 0o777, 0o600);
1108    }
1109
1110    #[cfg(unix)]
1111    #[test]
1112    fn newly_created_user_directories_are_private_but_existing_modes_are_preserved() {
1113        use std::os::unix::fs::{PermissionsExt, symlink};
1114
1115        let temp = tempdir().expect("tempdir");
1116        let paths = migration_paths(&temp);
1117        fs::create_dir_all(paths.config_dir()).expect("existing config directory");
1118        fs::set_permissions(paths.config_dir(), fs::Permissions::from_mode(0o755)).expect("set existing mode");
1119
1120        let _ = paths.ensure_config_dir().expect("preserve existing config directory");
1121        let _ = paths.ensure_data_dir().expect("create data directory");
1122        let _ = paths.ensure_state_child_dir("sessions").expect("create state child");
1123        let _ = paths.ensure_cache_child_dir("prompts").expect("create cache child");
1124        let _ = paths.ensure_executable_dir().expect("create executable directory");
1125
1126        assert_eq!(fs::metadata(paths.config_dir()).expect("config metadata").permissions().mode() & 0o777, 0o755);
1127        assert_eq!(fs::metadata(paths.data_dir()).expect("data metadata").permissions().mode() & 0o777, 0o700);
1128        assert_eq!(
1129            fs::metadata(paths.state_dir().join("sessions"))
1130                .expect("state child metadata")
1131                .permissions()
1132                .mode()
1133                & 0o777,
1134            0o700
1135        );
1136        assert_eq!(
1137            fs::metadata(paths.cache_dir().join("prompts"))
1138                .expect("cache child metadata")
1139                .permissions()
1140                .mode()
1141                & 0o777,
1142            0o700
1143        );
1144        assert_eq!(
1145            fs::metadata(paths.executable_dir())
1146                .expect("executable metadata")
1147                .permissions()
1148                .mode()
1149                & 0o777,
1150            0o700
1151        );
1152
1153        symlink(temp.path().join("outside"), paths.cache_dir().join("unsafe")).expect("create cache symlink");
1154        assert!(paths.ensure_cache_child_dir("unsafe/nested").is_err());
1155    }
1156
1157    #[test]
1158    fn migration_copies_explicit_categories_once_and_preserves_sources() {
1159        let temp = tempdir().expect("tempdir");
1160        let paths = migration_paths(&temp);
1161        fs::create_dir_all(paths.legacy_home_dir().join("plugins")).expect("legacy plugins");
1162        fs::write(paths.legacy_home_dir().join("vtcode.toml"), "theme = 'dark'").expect("legacy config");
1163        fs::write(paths.legacy_home_dir().join("plugins/example"), "plugin").expect("legacy plugin");
1164
1165        let first = paths.migrate_legacy().expect("migrate legacy data");
1166        let second = paths.migrate_legacy().expect("migrate idempotently");
1167
1168        assert_eq!(fs::read_to_string(paths.config_file()).expect("migrated config"), "theme = 'dark'");
1169        assert_eq!(fs::read_to_string(paths.plugins_dir().join("example")).expect("migrated plugin"), "plugin");
1170        assert!(paths.legacy_home_dir().join("vtcode.toml").exists());
1171        assert_eq!(first.migrated.len(), 2);
1172        assert!(first.marker_written);
1173        assert!(paths.migration_report_path().is_file());
1174        assert!(second.already_completed);
1175    }
1176
1177    #[test]
1178    fn migration_copies_user_guidance_and_prompt_configuration_to_config() {
1179        let temp = tempdir().expect("tempdir");
1180        let paths = migration_paths(&temp);
1181        fs::create_dir_all(paths.legacy_home_dir().join("prompts/examples")).expect("legacy prompts");
1182        fs::write(paths.legacy_home_dir().join("AGENTS.md"), "user guidance").expect("legacy guidance");
1183        fs::write(paths.legacy_home_dir().join("config.toml"), "enabled = true").expect("legacy dot config");
1184        fs::write(paths.legacy_home_dir().join("prompts/examples/example.md"), "# Example")
1185            .expect("legacy prompt example");
1186
1187        let report = paths.migrate_legacy().expect("migrate user configuration");
1188
1189        assert_eq!(
1190            fs::read_to_string(paths.config_dir().join("AGENTS.md")).expect("migrated guidance"),
1191            "user guidance"
1192        );
1193        assert_eq!(
1194            fs::read_to_string(paths.config_dir().join("config.toml")).expect("migrated dot config"),
1195            "enabled = true"
1196        );
1197        assert_eq!(
1198            fs::read_to_string(paths.config_dir().join("prompts/examples/example.md")).expect("migrated prompt"),
1199            "# Example"
1200        );
1201        assert!(report.migrated.len() >= 3);
1202        assert!(paths.legacy_home_dir().join("prompts/examples/example.md").is_file());
1203    }
1204
1205    #[test]
1206    fn migration_copies_pre_xdg_config_root_cache_and_state() {
1207        let temp = tempdir().expect("tempdir");
1208        let paths = migration_paths(&temp);
1209        let old_cache_file = paths.config_dir().join("cache/models/dynamic_local_models.json");
1210        let old_log_file = paths.config_dir().join("logs/session.log");
1211        let old_session_file = paths.config_dir().join("sessions/session.jsonl");
1212        let old_backup_file = paths.config_dir().join("backups/config.toml");
1213        for path in [&old_cache_file, &old_log_file, &old_session_file, &old_backup_file] {
1214            fs::create_dir_all(path.parent().expect("legacy parent")).expect("create legacy parent");
1215            fs::write(path, path.file_name().expect("file name").to_string_lossy().as_bytes())
1216                .expect("write legacy file");
1217        }
1218
1219        let report = paths.migrate_legacy().expect("migrate pre-XDG config data");
1220
1221        for (source, destination) in [
1222            (old_cache_file, paths.cache_dir().join("models/dynamic_local_models.json")),
1223            (old_log_file, paths.state_dir().join("logs/session.log")),
1224            (old_session_file, paths.state_dir().join("sessions/session.jsonl")),
1225            (old_backup_file, paths.state_dir().join("backups/config.toml")),
1226        ] {
1227            assert!(source.is_file());
1228            assert_eq!(fs::read(&source).expect("read source"), fs::read(&destination).expect("read destination"));
1229        }
1230        assert!(report.migrated.len() >= 4);
1231    }
1232
1233    #[test]
1234    fn migration_copies_legacy_installer_backoff_caches() {
1235        let temp = tempdir().expect("tempdir");
1236        let paths = migration_paths(&temp);
1237        let old_ast_cache = paths.legacy_home_dir().join("ast_grep_install_cache.json");
1238        let old_ripgrep_cache = paths.legacy_home_dir().join("ripgrep_install_cache.json");
1239        fs::create_dir_all(paths.legacy_home_dir()).expect("legacy home directory");
1240        for path in [&old_ast_cache, &old_ripgrep_cache] {
1241            fs::write(path, path.file_name().expect("file name").to_string_lossy().as_bytes())
1242                .expect("write legacy installer cache");
1243        }
1244
1245        let report = paths.migrate_legacy().expect("migrate installer caches");
1246
1247        for (source, destination) in [
1248            (old_ast_cache, paths.cache_dir().join("ast-grep/install.json")),
1249            (old_ripgrep_cache, paths.cache_dir().join("ripgrep/ripgrep_install_cache.json")),
1250        ] {
1251            assert!(source.is_file());
1252            assert_eq!(fs::read(&source).expect("read source"), fs::read(destination).expect("read destination"));
1253        }
1254        assert!(report.migrated.len() >= 2);
1255    }
1256
1257    #[test]
1258    fn migration_reports_conflicts_and_excludes_tmp() {
1259        let temp = tempdir().expect("tempdir");
1260        let paths = migration_paths(&temp);
1261        fs::create_dir_all(paths.legacy_home_dir()).expect("legacy root");
1262        fs::write(paths.legacy_home_dir().join("vtcode.toml"), "legacy").expect("legacy config");
1263        fs::write(paths.legacy_home_dir().join("tmp"), "temporary").expect("legacy temporary file");
1264        fs::create_dir_all(paths.config_dir()).expect("config root");
1265        fs::write(paths.config_file(), "current").expect("current config");
1266
1267        let report = paths.migrate_legacy().expect("migrate with conflict");
1268
1269        assert_eq!(fs::read_to_string(paths.config_file()).expect("current config"), "current");
1270        assert!(
1271            report
1272                .skipped
1273                .iter()
1274                .any(|skip| skip.reason == MigrationSkipReason::DestinationExists)
1275        );
1276        assert!(report.skipped.iter().any(|skip| skip.reason == MigrationSkipReason::Excluded));
1277        assert!(!paths.runtime_dir().join("tmp").exists());
1278    }
1279
1280    #[test]
1281    fn migration_does_not_trust_legacy_migration_metadata() {
1282        let temp = tempdir().expect("tempdir");
1283        let paths = migration_paths(&temp);
1284        let legacy_migration = paths.legacy_home_dir().join("state/migration");
1285        fs::create_dir_all(&legacy_migration).expect("legacy migration directory");
1286        fs::write(legacy_migration.join("legacy-v1.complete"), "spoofed\n").expect("spoofed marker");
1287
1288        let report = paths.migrate_legacy().expect("migrate legacy metadata");
1289
1290        assert!(report.marker_written);
1291        assert_eq!(
1292            fs::read_to_string(paths.migration_marker_path()).expect("current migration marker"),
1293            "legacy migration completed\n"
1294        );
1295        assert!(
1296            !report
1297                .migrated
1298                .iter()
1299                .any(|entry| entry.destination == paths.migration_marker_path())
1300        );
1301        assert!(report.skipped.iter().any(|skip| {
1302            skip.path == paths.legacy_home_dir().join("state/migration") && skip.reason == MigrationSkipReason::Excluded
1303        }));
1304    }
1305
1306    #[cfg(unix)]
1307    #[test]
1308    fn private_file_writer_rejects_symlink_escape_and_final_symlink() {
1309        use std::os::unix::fs::symlink;
1310
1311        let temp = tempdir().expect("tempdir");
1312        let outside = temp.path().join("outside");
1313        fs::create_dir_all(&outside).expect("outside directory");
1314        let escaped_parent = temp.path().join("escaped");
1315        symlink(&outside, &escaped_parent).expect("escape symlink");
1316
1317        assert!(VtCodePaths::write_private_file_atomic(escaped_parent.join("data"), b"blocked").is_err());
1318        assert!(!outside.join("data").exists());
1319
1320        let safe_parent = temp.path().join("safe");
1321        fs::create_dir_all(&safe_parent).expect("safe directory");
1322        let destination = safe_parent.join("data");
1323        fs::write(&destination, "original").expect("destination");
1324        let linked = safe_parent.join("linked");
1325        symlink(&destination, &linked).expect("final symlink");
1326        assert!(VtCodePaths::write_private_file_atomic(&linked, b"blocked").is_err());
1327        assert_eq!(fs::read_to_string(destination).expect("original data"), "original");
1328    }
1329
1330    #[test]
1331    fn private_file_writer_replaces_existing_file_without_temporary_leaks() {
1332        let temp = tempdir().expect("tempdir");
1333        let destination = temp.path().join("cache/data");
1334
1335        VtCodePaths::write_private_file_atomic(&destination, b"old committed state").expect("first write");
1336        VtCodePaths::write_private_file_atomic(&destination, b"new").expect("replace existing file");
1337
1338        assert_eq!(fs::read(&destination).expect("read replacement"), b"new");
1339        assert_eq!(fs::read_dir(destination.parent().expect("parent")).expect("directory").count(), 1);
1340    }
1341
1342    #[cfg(windows)]
1343    #[test]
1344    fn private_file_writer_failed_replacement_preserves_committed_file_and_cleans_temporary() {
1345        use std::os::windows::fs::OpenOptionsExt;
1346
1347        let temp = tempdir().expect("tempdir");
1348        let destination = temp.path().join("data");
1349        VtCodePaths::write_private_file_atomic(&destination, b"committed").expect("committed write");
1350        let locked = OpenOptions::new()
1351            .read(true)
1352            .share_mode(0)
1353            .open(&destination)
1354            .expect("lock destination against replacement");
1355
1356        let error = VtCodePaths::write_private_file_atomic(&destination, b"rejected replacement")
1357            .expect_err("replacement must fail while destination is locked");
1358        assert!(error.to_string().contains("could not atomically write"));
1359        drop(locked);
1360
1361        assert_eq!(fs::read(&destination).expect("read committed state"), b"committed");
1362        assert_eq!(fs::read_dir(temp.path()).expect("directory").count(), 1);
1363    }
1364
1365    #[test]
1366    fn private_file_writer_if_absent_does_not_replace_existing_file() {
1367        let temp = tempdir().expect("tempdir");
1368        let destination = temp.path().join("cache/data");
1369
1370        assert!(VtCodePaths::write_private_file_atomic_if_absent(&destination, b"first").expect("create file"));
1371        assert!(!VtCodePaths::write_private_file_atomic_if_absent(&destination, b"second").expect("keep file"));
1372        assert_eq!(fs::read(&destination).expect("read file"), b"first");
1373    }
1374
1375    #[test]
1376    fn private_file_lock_releases_after_operation() {
1377        let temp = tempdir().expect("tempdir");
1378        let destination = temp.path().join("cache/data");
1379        let lock_path = destination.parent().expect("cache parent").join(".data.lock");
1380
1381        let result =
1382            VtCodePaths::with_private_file_lock(&destination, || Ok::<_, anyhow::Error>(17)).expect("lock operation");
1383
1384        assert_eq!(result, 17);
1385        assert!(lock_path.is_file());
1386        assert_eq!(
1387            VtCodePaths::with_private_file_lock(&destination, || Ok::<_, anyhow::Error>(23))
1388                .expect("lock can be reused"),
1389            23
1390        );
1391    }
1392
1393    #[test]
1394    fn private_file_lock_serializes_concurrent_operations() {
1395        use std::sync::{
1396            Arc, Barrier,
1397            atomic::{AtomicUsize, Ordering},
1398        };
1399
1400        let temp = tempdir().expect("tempdir");
1401        let destination = Arc::new(temp.path().join("cache/data"));
1402        let start = Arc::new(Barrier::new(4));
1403        let active = Arc::new(AtomicUsize::new(0));
1404        let max_active = Arc::new(AtomicUsize::new(0));
1405        let handles = (0..4)
1406            .map(|_| {
1407                let destination = Arc::clone(&destination);
1408                let start = Arc::clone(&start);
1409                let active = Arc::clone(&active);
1410                let max_active = Arc::clone(&max_active);
1411                std::thread::spawn(move || {
1412                    let _ = start.wait();
1413                    VtCodePaths::with_private_file_lock(destination.as_ref(), || {
1414                        let current = active.fetch_add(1, Ordering::SeqCst) + 1;
1415                        let _ = max_active.fetch_max(current, Ordering::SeqCst);
1416                        std::thread::sleep(Duration::from_millis(25));
1417                        let _ = active.fetch_sub(1, Ordering::SeqCst);
1418                        Ok::<_, anyhow::Error>(())
1419                    })
1420                })
1421            })
1422            .collect::<Vec<_>>();
1423
1424        for handle in handles {
1425            handle.join().expect("lock thread panicked").expect("lock operation");
1426        }
1427        assert_eq!(max_active.load(Ordering::SeqCst), 1);
1428    }
1429
1430    #[cfg(unix)]
1431    #[test]
1432    fn migration_skips_symlinks_and_special_files_without_traversing_them() {
1433        use std::os::unix::fs::symlink;
1434
1435        let temp = tempdir().expect("tempdir");
1436        let paths = migration_paths(&temp);
1437        let outside = temp.path().join("outside");
1438        fs::create_dir_all(&outside).expect("outside root");
1439        fs::write(outside.join("secret"), "secret").expect("outside secret");
1440        fs::create_dir_all(paths.legacy_home_dir().join("plugins")).expect("legacy plugins");
1441        symlink(&outside, paths.legacy_home_dir().join("plugins/link")).expect("legacy symlink");
1442        let socket = paths.legacy_home_dir().join("plugins/socket");
1443        let _listener = std::os::unix::net::UnixListener::bind(&socket).expect("create unix socket");
1444
1445        let report = paths.migrate_legacy().expect("migrate safely");
1446
1447        assert!(report.skipped.iter().any(|skip| skip.reason == MigrationSkipReason::Symlink));
1448        assert!(
1449            report
1450                .skipped
1451                .iter()
1452                .any(|skip| skip.reason == MigrationSkipReason::SpecialFile)
1453        );
1454        assert!(!paths.plugins_dir().join("link/secret").exists());
1455    }
1456
1457    #[test]
1458    fn migration_retries_destination_failures_before_writing_marker() {
1459        let temp = tempdir().expect("tempdir");
1460        let paths = migration_paths(&temp);
1461        fs::create_dir_all(paths.legacy_home_dir()).expect("legacy root");
1462        fs::write(paths.legacy_home_dir().join("vtcode.toml"), "legacy").expect("legacy config");
1463        fs::write(paths.config_dir(), "unsafe root").expect("unsafe config root");
1464
1465        let first_report = paths.migrate_legacy().expect("migration report");
1466
1467        assert!(!first_report.failures.is_empty());
1468        assert!(!first_report.marker_written);
1469
1470        fs::remove_file(paths.config_dir()).expect("remove blocked config root");
1471        let second_report = paths.migrate_legacy().expect("retry migration");
1472
1473        assert!(second_report.marker_written);
1474        assert_eq!(fs::read_to_string(paths.config_file()).expect("migrated config"), "legacy");
1475    }
1476}