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