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