Skip to main content

vtcode_commons/vtcode_paths/
mod.rs

1//! Native and XDG-compliant storage paths for VT Code.
2
3use std::collections::BTreeMap;
4use std::ffi::{OsStr, OsString};
5use std::fs::{self, File, OpenOptions};
6use std::io::{self, Read, Write};
7use std::path::{Component, Path, PathBuf};
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10use anyhow::{Context, Result, anyhow, bail};
11use fs2::FileExt;
12
13use crate::paths::validate_no_escaping_symlink_ancestors;
14
15#[path = "../vtcode_paths_migration.rs"]
16mod migration;
17pub use migration::{
18    LegacyMigrator, MigrationEntry, MigrationFailure, MigrationReport, MigrationSkip, MigrationSkipReason,
19};
20
21const APP: &str = "vtcode";
22const MARKER: &str = "legacy-v1.complete";
23const PRIVATE_FILE_LOCK_ATTEMPTS: usize = 100;
24const PRIVATE_FILE_LOCK_DELAY: Duration = Duration::from_millis(10);
25
26struct NativeRoots {
27    config_dir: PathBuf,
28    data_dir: PathBuf,
29    state_dir: PathBuf,
30    cache_dir: PathBuf,
31    runtime_dir: Option<PathBuf>,
32    executable_dir: PathBuf,
33}
34
35fn native_roots(
36    #[cfg_attr(
37        target_os = "windows",
38        allow(
39            unused_variables,
40            reason = "home_dir is only consumed by macOS and XDG root resolution; Windows uses known-folder APIs"
41        )
42    )]
43    home_dir: &Path,
44) -> Result<NativeRoots> {
45    #[cfg(target_os = "macos")]
46    {
47        let root = dirs::data_local_dir()
48            .ok_or_else(|| anyhow!("could not determine the macOS application support directory"))?
49            .join("com.vinhnx.vtcode");
50        Ok(NativeRoots {
51            config_dir: root.clone(),
52            data_dir: root.clone(),
53            state_dir: root.join("state"),
54            cache_dir: dirs::cache_dir()
55                .ok_or_else(|| anyhow!("could not determine the macOS cache directory"))?
56                .join("com.vinhnx.vtcode"),
57            runtime_dir: None,
58            executable_dir: home_dir.join(".local/bin"),
59        })
60    }
61    #[cfg(target_os = "windows")]
62    {
63        let root = dirs::data_dir()
64            .ok_or_else(|| anyhow!("could not determine the Windows application data directory"))?
65            .join("vinhnx")
66            .join(APP);
67        Ok(NativeRoots {
68            config_dir: root.join("config"),
69            data_dir: root.join("data"),
70            state_dir: root.join("state"),
71            cache_dir: root.join("cache"),
72            runtime_dir: None,
73            executable_dir: root.join("bin"),
74        })
75    }
76    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
77    {
78        Ok(NativeRoots {
79            config_dir: home_dir.join(".config").join(APP),
80            data_dir: home_dir.join(".local/share").join(APP),
81            state_dir: home_dir.join(".local/state").join(APP),
82            cache_dir: home_dir.join(".cache").join(APP),
83            runtime_dir: None,
84            executable_dir: home_dir.join(".local/bin"),
85        })
86    }
87}
88
89/// Validated native/XDG storage roots and typed VT Code child paths.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct VtCodePaths {
92    config_dir: PathBuf,
93    data_dir: PathBuf,
94    state_dir: PathBuf,
95    cache_dir: PathBuf,
96    runtime_dir: PathBuf,
97    executable_dir: PathBuf,
98    system_config_dirs: Vec<PathBuf>,
99    system_data_dirs: Vec<PathBuf>,
100    legacy_home_dir: PathBuf,
101}
102
103impl VtCodePaths {
104    /// Resolves paths from the process environment.
105    pub fn from_env() -> Result<Self> {
106        Self::from_environment_os(&std::env::vars_os().collect())
107    }
108
109    /// Alias for [`Self::from_env`], useful during startup resolution.
110    pub fn resolve() -> Result<Self> {
111        Self::from_env()
112    }
113
114    /// Resolves paths from an explicit environment, suitable for deterministic tests.
115    pub fn from_environment(environment: &[(&str, &str)]) -> Result<Self> {
116        Self::from_environment_os(
117            &environment
118                .iter()
119                .map(|(key, value)| (OsString::from(key), OsString::from(value)))
120                .collect(),
121        )
122    }
123
124    fn from_environment_os(environment: &BTreeMap<OsString, OsString>) -> Result<Self> {
125        let home_dir = environment
126            .get(OsStr::new("HOME"))
127            .filter(|value| !value.is_empty())
128            .map(PathBuf::from)
129            .filter(|path| path.is_absolute())
130            .or_else(dirs::home_dir)
131            .ok_or_else(|| anyhow!("could not determine the user home directory"))?;
132        let native = native_roots(&home_dir)?;
133        let home_override = env_path(environment, "VTCODE_HOME");
134        if let Some(path) = &home_override {
135            validate_absolute("VTCODE_HOME", path)?;
136        }
137        let legacy_home_dir = home_override.clone().unwrap_or_else(|| home_dir.join(".vtcode"));
138
139        let config_dir = match env_path(environment, "VTCODE_CONFIG") {
140            Some(path) => {
141                validate_absolute("VTCODE_CONFIG", &path)?;
142                path
143            }
144            None => xdg_app_dir(environment, "XDG_CONFIG_HOME", &native.config_dir)?,
145        };
146        let data_dir = match env_path(environment, "VTCODE_DATA") {
147            Some(path) => {
148                validate_absolute("VTCODE_DATA", &path)?;
149                path
150            }
151            None => xdg_app_dir(environment, "XDG_DATA_HOME", &native.data_dir)?,
152        };
153        let state_dir = xdg_app_dir(environment, "XDG_STATE_HOME", &native.state_dir)?;
154        let cache_dir = xdg_app_dir(environment, "XDG_CACHE_HOME", &native.cache_dir)?;
155        let runtime_dir = match () {
156            _ if is_xdg_platform() => match env_path(environment, "XDG_RUNTIME_DIR") {
157                Some(path) if path.is_absolute() => path.join(APP),
158                None => state_dir.join("runtime"),
159                Some(_) => state_dir.join("runtime"),
160            },
161            _ => native.runtime_dir.unwrap_or_else(|| state_dir.join("runtime")),
162        };
163        for (name, path) in [
164            ("configuration directory", &config_dir),
165            ("data directory", &data_dir),
166            ("state directory", &state_dir),
167            ("cache directory", &cache_dir),
168            ("runtime directory", &runtime_dir),
169        ] {
170            validate_absolute(name, path)?;
171        }
172        Ok(Self {
173            config_dir,
174            data_dir,
175            state_dir,
176            cache_dir,
177            runtime_dir,
178            executable_dir: executable_dir(environment, native.executable_dir)?,
179            system_config_dirs: system_config_dirs(environment)?,
180            system_data_dirs: system_data_dirs(environment)?,
181            legacy_home_dir,
182        })
183    }
184
185    /// Canonical directory for user configuration.
186    pub fn config_dir(&self) -> &Path {
187        &self.config_dir
188    }
189    /// Canonical directory for durable user data.
190    pub fn data_dir(&self) -> &Path {
191        &self.data_dir
192    }
193    /// Canonical directory for durable mutable state.
194    pub fn state_dir(&self) -> &Path {
195        &self.state_dir
196    }
197    /// Canonical directory for recreatable cached data.
198    pub fn cache_dir(&self) -> &Path {
199        &self.cache_dir
200    }
201    /// Private directory for runtime-only files.
202    pub fn runtime_dir(&self) -> &Path {
203        &self.runtime_dir
204    }
205    /// User executable helper directory.
206    pub fn executable_dir(&self) -> &Path {
207        &self.executable_dir
208    }
209    /// System configuration candidates, lowest priority first.
210    pub fn system_config_dirs(&self) -> &[PathBuf] {
211        &self.system_config_dirs
212    }
213    /// System data candidates without an application suffix.
214    pub fn system_data_dirs(&self) -> &[PathBuf] {
215        &self.system_data_dirs
216    }
217    /// Resolves a relative config path against all system candidates in
218    /// low-to-high precedence order. `system_config_dirs()` retains the XDG
219    /// preference order, where the first directory is most important.
220    pub fn system_config_paths(&self, relative: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
221        let relative = relative.as_ref();
222        validate_relative_path("system configuration path", relative)?;
223        let mut paths = if cfg!(unix) {
224            vec![PathBuf::from("/etc/vtcode").join(relative)]
225        } else {
226            Vec::new()
227        };
228        paths.extend(self.system_config_dirs.iter().rev().map(|base| base.join(APP).join(relative)));
229        paths.dedup();
230        Ok(paths)
231    }
232    /// Resolves a relative data path against all system candidates.
233    pub fn system_data_paths(&self, relative: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
234        let relative = relative.as_ref();
235        validate_relative_path("system data path", relative)?;
236        Ok(self.system_data_dirs.iter().map(|base| base.join(APP).join(relative)).collect())
237    }
238    /// Legacy global VT Code root, used only as a migration source.
239    pub fn legacy_home_dir(&self) -> &Path {
240        &self.legacy_home_dir
241    }
242
243    /// Alias for [`Self::legacy_home_dir`] using the public contract name.
244    pub fn legacy_dir(&self) -> &Path {
245        self.legacy_home_dir()
246    }
247
248    /// Resolve a relative child path under the canonical configuration root.
249    pub fn config_path(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
250        child_path(&self.config_dir, relative.as_ref(), "configuration")
251    }
252
253    /// Resolve a relative child path under the canonical data root.
254    pub fn data_path(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
255        child_path(&self.data_dir, relative.as_ref(), "data")
256    }
257
258    /// Resolve a relative child path under the persistent state root.
259    pub fn state_path(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
260        child_path(&self.state_dir, relative.as_ref(), "state")
261    }
262
263    /// Resolve a relative child path under the cache root.
264    pub fn cache_path(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
265        child_path(&self.cache_dir, relative.as_ref(), "cache")
266    }
267
268    /// Resolve a relative child path under the runtime root.
269    pub fn runtime_path(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
270        child_path(&self.runtime_dir, relative.as_ref(), "runtime")
271    }
272
273    /// Resolve a relative child path under the managed executable root.
274    pub fn executable_path(&self, relative: impl AsRef<Path>) -> Result<PathBuf> {
275        child_path(&self.executable_dir, relative.as_ref(), "executable")
276    }
277
278    /// Main user configuration file.
279    pub fn config_file(&self) -> PathBuf {
280        self.config_dir.join("vtcode.toml")
281    }
282    /// User-installed and downloaded skills.
283    pub fn skills_dir(&self) -> PathBuf {
284        self.data_dir.join("skills")
285    }
286    /// User-installed plugins.
287    pub fn plugins_dir(&self) -> PathBuf {
288        self.data_dir.join("plugins")
289    }
290    /// Private authentication storage.
291    pub fn auth_dir(&self) -> PathBuf {
292        self.config_dir.join("auth")
293    }
294    /// Legacy plaintext authentication file location.
295    pub fn auth_file(&self) -> PathBuf {
296        self.auth_dir().join("auth.json")
297    }
298    /// Durable log files.
299    pub fn logs_dir(&self) -> PathBuf {
300        self.state_dir.join("logs")
301    }
302    /// Persisted cross-workspace session data.
303    pub fn sessions_dir(&self) -> PathBuf {
304        self.state_dir.join("sessions")
305    }
306    /// Telemetry storage.
307    pub fn telemetry_dir(&self) -> PathBuf {
308        self.state_dir.join("telemetry")
309    }
310    /// Completion marker for one-time legacy migration.
311    pub fn migration_marker_path(&self) -> PathBuf {
312        self.state_dir.join("migration").join(MARKER)
313    }
314
315    /// Versioned diagnostic report emitted alongside the completion marker.
316    pub fn migration_report_path(&self) -> PathBuf {
317        self.state_dir.join("migration").join("legacy-v1.json")
318    }
319
320    /// Creates the runtime directory with private permissions.
321    pub fn ensure_runtime_dir(&self) -> Result<&Path> {
322        ensure_private_dir(&self.runtime_dir).context("could not create VT Code runtime directory")?;
323        Ok(&self.runtime_dir)
324    }
325
326    /// Creates the canonical user configuration directory when needed.
327    /// Existing directories retain their permissions.
328    pub fn ensure_config_dir(&self) -> Result<&Path> {
329        ensure_user_dir(&self.config_dir).context("could not create VT Code configuration directory")?;
330        Ok(&self.config_dir)
331    }
332
333    /// Creates the canonical user data directory when needed.
334    /// Existing directories retain their permissions.
335    pub fn ensure_data_dir(&self) -> Result<&Path> {
336        ensure_user_dir(&self.data_dir).context("could not create VT Code data directory")?;
337        Ok(&self.data_dir)
338    }
339
340    /// Creates the canonical user state directory when needed.
341    /// Existing directories retain their permissions.
342    pub fn ensure_state_dir(&self) -> Result<&Path> {
343        ensure_user_dir(&self.state_dir).context("could not create VT Code state directory")?;
344        Ok(&self.state_dir)
345    }
346
347    /// Creates the canonical user cache directory when needed.
348    /// Existing directories retain their permissions.
349    pub fn ensure_cache_dir(&self) -> Result<&Path> {
350        ensure_user_dir(&self.cache_dir).context("could not create VT Code cache directory")?;
351        Ok(&self.cache_dir)
352    }
353
354    /// Creates the managed executable directory when needed.
355    /// Existing directories retain their permissions.
356    pub fn ensure_executable_dir(&self) -> Result<&Path> {
357        ensure_user_dir(&self.executable_dir).context("could not create VT Code executable directory")?;
358        Ok(&self.executable_dir)
359    }
360
361    /// Creates an arbitrary user-owned directory without following symlinks.
362    /// Existing directories retain their permissions; newly created Unix
363    /// directories use mode `0700`.
364    pub fn ensure_user_dir(path: impl AsRef<Path>) -> Result<PathBuf> {
365        let path = path.as_ref();
366        ensure_user_dir(path).with_context(|| format!("could not create user directory {}", path.display()))?;
367        Ok(path.to_path_buf())
368    }
369
370    /// Creates a new private file without following a final symlink.
371    ///
372    /// The parent is validated component by component before the file is
373    /// opened. This is the common primitive for caches, locks, and other
374    /// category-owned files that must never be redirected through a symlink.
375    pub fn create_private_file(path: impl AsRef<Path>) -> Result<File> {
376        let path = path.as_ref();
377        ensure_file_parent(path)?;
378        create_private_new_file(path).with_context(|| format!("could not create private file {}", path.display()))
379    }
380
381    /// Opens an append-only private file without following a final symlink.
382    pub fn open_private_append_file(path: impl AsRef<Path>) -> Result<File> {
383        let path = path.as_ref();
384        ensure_file_parent(path)?;
385        open_private_append(path).with_context(|| format!("could not open private file {}", path.display()))
386    }
387
388    /// Reads a regular file without following a final symlink.
389    pub fn read_file_no_follow(path: impl AsRef<Path>) -> Result<Vec<u8>> {
390        let path = path.as_ref();
391        validate_no_escaping_symlink_ancestors(path, false)
392            .with_context(|| format!("could not validate file path {}", path.display()))?;
393        let mut file = open_no_follow(path).with_context(|| format!("could not open file {}", path.display()))?;
394        let metadata = file
395            .metadata()
396            .with_context(|| format!("could not inspect file {}", path.display()))?;
397        if !metadata.is_file() {
398            bail!("{} is not a regular file", path.display());
399        }
400        let mut contents = Vec::new();
401        let _bytes_read = file
402            .read_to_end(&mut contents)
403            .with_context(|| format!("could not read file {}", path.display()))?;
404        Ok(contents)
405    }
406
407    /// Atomically writes a private file, replacing an existing regular file.
408    ///
409    /// The destination is never opened for writing. A private, exclusive
410    /// temporary file is written and then renamed into place, so a pre-existing
411    /// symlink cannot redirect the contents outside the approved parent.
412    pub fn write_private_file_atomic(path: impl AsRef<Path>, contents: &[u8]) -> Result<()> {
413        let destination = path.as_ref();
414        ensure_file_parent(destination)?;
415        validate_file_destination(destination)?;
416        let parent = destination
417            .parent()
418            .ok_or_else(|| anyhow!("private file {} has no parent", destination.display()))?;
419        let stem = destination.file_name().unwrap_or_else(|| OsStr::new("file"));
420        let (temporary, mut file) = unique_private_file(parent, stem)?;
421        let result: io::Result<()> = (|| {
422            file.write_all(contents)?;
423            file.sync_all()?;
424            drop(file);
425            // rename replaces existing files on Windows too. Removing the
426            // destination first would lose the last committed file on failure.
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    /// Atomically publishes a private file only when the destination is absent.
436    ///
437    /// The temporary file is linked into place instead of renamed over the
438    /// destination. This makes legacy-cache republishing safe when multiple
439    /// VT Code processes initialize the same cache concurrently: the first
440    /// publisher wins and a newer canonical cache cannot be clobbered.
441    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    /// Runs an operation while holding an exclusive private lock adjacent to a file.
469    ///
470    /// Lock acquisition is bounded and released by the operating system when
471    /// the owning process exits, so an abandoned lock cannot permanently
472    /// disable cache writes.
473    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    /// Creates a private runtime child directory after validating its path.
486    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    /// Creates a user configuration child directory after validating its path.
493    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    /// Creates a user data child directory after validating its path.
500    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    /// Creates a user state child directory after validating its path.
507    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    /// Creates a user cache child directory after validating its path.
514    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    /// Creates a managed executable child directory after validating its path.
521    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    /// Creates private auth storage and returns its path.
528    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    /// Creates an empty, private auth file. The name must not contain a path.
535    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    /// Copies eligible legacy global data without modifying its source.
547    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
663/// Ensure a user-owned directory exists without changing the mode of any
664/// directory that was already present. Every directory created by this
665/// helper is private on Unix.
666fn 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
719/// Ensure migration-created directories are private while preserving the mode
720/// of directories that already existed at the destination.
721fn 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    let parent = if parent.as_os_str().is_empty() {
800        Path::new(".")
801    } else {
802        parent
803    };
804    ensure_user_dir(parent).with_context(|| format!("could not create file parent {}", parent.display()))?;
805    Ok(())
806}
807
808fn validate_file_destination(path: &Path) -> Result<()> {
809    match fs::symlink_metadata(path) {
810        Ok(metadata) if metadata.file_type().is_symlink() => {
811            bail!("refusing to replace symlinked file {}", path.display())
812        }
813        Ok(metadata) if !metadata.is_file() => bail!("{} is not a regular file", path.display()),
814        Ok(_) => Ok(()),
815        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
816        Err(error) => Err(error).with_context(|| format!("could not inspect {}", path.display())),
817    }
818}
819
820fn unique_private_file(parent: &Path, stem: &OsStr) -> Result<(PathBuf, File)> {
821    let timestamp = SystemTime::now()
822        .duration_since(UNIX_EPOCH)
823        .map(|duration| duration.as_nanos())
824        .unwrap_or_default();
825    let stem = stem.to_string_lossy();
826    for attempt in 0..32u8 {
827        let temporary = parent.join(format!(".{stem}.{}.{}.{}.tmp", std::process::id(), timestamp, attempt));
828        match create_private_new_file(&temporary) {
829            Ok(file) => return Ok((temporary, file)),
830            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
831            Err(error) => return Err(error).with_context(|| format!("could not create {}", temporary.display())),
832        }
833    }
834    bail!("could not allocate a unique private temporary file in {}", parent.display())
835}
836
837struct PrivateFileLock {
838    _file: File,
839}
840
841fn acquire_private_file_lock(path: &Path) -> Result<PrivateFileLock> {
842    let file = open_private_lock_file(path)?;
843    for attempt in 0..PRIVATE_FILE_LOCK_ATTEMPTS {
844        match file.try_lock_exclusive() {
845            Ok(()) => return Ok(PrivateFileLock { _file: file }),
846            Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
847                if attempt + 1 < PRIVATE_FILE_LOCK_ATTEMPTS {
848                    std::thread::sleep(PRIVATE_FILE_LOCK_DELAY);
849                }
850            }
851            Err(error) => return Err(error).with_context(|| format!("could not lock {}", path.display())),
852        }
853    }
854    bail!("timed out waiting for private file lock {}", path.display())
855}
856
857fn open_private_lock_file(path: &Path) -> Result<File> {
858    match create_private_lock_file(path) {
859        Ok(file) => Ok(file),
860        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
861            validate_file_destination(path)?;
862            let mut options = OpenOptions::new();
863            let _ = options.read(true).write(true);
864            #[cfg(unix)]
865            {
866                use std::os::unix::fs::OpenOptionsExt;
867                let _ = options.custom_flags(libc::O_NOFOLLOW);
868            }
869            options
870                .open(path)
871                .with_context(|| format!("could not open private lock {}", path.display()))
872        }
873        Err(error) => Err(error).with_context(|| format!("could not create lock {}", path.display())),
874    }
875}
876
877fn create_private_lock_file(path: &Path) -> io::Result<File> {
878    let mut options = OpenOptions::new();
879    let _ = options.read(true).write(true).create_new(true);
880    #[cfg(unix)]
881    {
882        use std::os::unix::fs::OpenOptionsExt;
883        let _ = options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
884    }
885    options.open(path)
886}
887
888impl Drop for PrivateFileLock {
889    fn drop(&mut self) {
890        if let Err(error) = self._file.unlock() {
891            tracing::debug!(%error, "failed to release private file lock");
892        }
893    }
894}
895
896fn remove_temporary_file(path: &Path) {
897    if let Err(error) = fs::remove_file(path)
898        && error.kind() != io::ErrorKind::NotFound
899    {
900        tracing::debug!(path = %path.display(), %error, "failed to remove private temporary file");
901    }
902}
903#[cfg_attr(
904    not(unix),
905    allow(unused_variables, reason = "only the Unix permission block consumes the path")
906)]
907fn set_private_permissions(path: &Path) -> io::Result<()> {
908    #[cfg(unix)]
909    {
910        use std::os::unix::fs::PermissionsExt;
911        fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
912    }
913    Ok(())
914}
915
916#[cfg(test)]
917mod tests;