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