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