Skip to main content

osdk_core/
dirs.rs

1//! Resolution of osdk's on-disk directories (data / store / installs / shims /
2//! cache), honoring `OSDK_*` env overrides, then falling back to the platform
3//! conventions provided by the `directories` crate (XDG on Linux).
4//!
5//! Layout under the data dir:
6//! ```text
7//! $OSDK_DATA_DIR (default ~/.local/share/osdk)
8//! ├── store/                 content-addressed blobs   (OSDK_STORE_DIR)
9//! ├── installs/<tool>/<ver>/ materialized tool versions (OSDK_INSTALL_DIR)
10//! ├── models/<name>/          materialized model snapshots
11//! ├── shims/                 shim launchers + osdk-shim
12//! ├── rustup/  cargo/        self-contained homes for delegate backends
13//! └── plugins/               future external backends
14//!
15//! $OSDK_CACHE_DIR (default ~/.cache/osdk)
16//! ├── downloads/  tmp/  remote/  sources/
17//! ```
18
19use std::path::{Path, PathBuf};
20
21use crate::error::{Error, Result};
22use crate::tool::{InstallIdentity, InstallScope};
23
24/// Environment variable names for directory overrides.
25pub mod env_keys {
26    pub const DATA_DIR: &str = "OSDK_DATA_DIR";
27    pub const CACHE_DIR: &str = "OSDK_CACHE_DIR";
28    pub const CONFIG_DIR: &str = "OSDK_CONFIG_DIR";
29    pub const STORE_DIR: &str = "OSDK_STORE_DIR";
30    pub const INSTALL_DIR: &str = "OSDK_INSTALL_DIR";
31}
32
33#[derive(Debug, Clone)]
34pub struct Dirs {
35    /// Root for persistent state (installs, store, shims).
36    pub data: PathBuf,
37    /// Root for disposable cache (downloads, extraction scratch, indices).
38    pub cache: PathBuf,
39    /// Root for user config files.
40    pub config: PathBuf,
41    /// Content-addressed store. Defaults to `data/store` (same volume ⇒
42    /// hardlinks work out of the box).
43    pub store: PathBuf,
44    /// Where materialized versions live. Defaults to `data/installs`.
45    pub installs: PathBuf,
46}
47
48/// All filesystem locations derived from one validated dynamic install identity.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct InstallLocator {
51    identity: InstallIdentity,
52    installs_root: PathBuf,
53    install_root: PathBuf,
54    legacy_install_root: PathBuf,
55    legacy_global_install_root: Option<PathBuf>,
56    lock_path: PathBuf,
57    scratch_root: PathBuf,
58}
59
60impl InstallLocator {
61    pub fn new(dirs: &Dirs, identity: InstallIdentity) -> Result<Self> {
62        identity.validate()?;
63        let component = install_id_component(&identity.install_id)?;
64        let legacy_install_root = dirs.install_path(&identity.tool, &identity.version);
65        let legacy_global_install_root = identity
66            .tool
67            .strip_prefix("npm:")
68            .map(|package| dirs.install_path(&format!("npm-global:{package}"), &identity.version));
69        let install_root = match identity.scope {
70            InstallScope::Isolated => legacy_install_root.join(&component),
71            InstallScope::Global => legacy_global_install_root
72                .as_ref()
73                .ok_or_else(|| Error::config("global dynamic installs are supported only for npm"))?
74                .join(&component),
75            InstallScope::ProjectManaged => {
76                return Err(Error::config(
77                    "project-managed tools do not have an osdk install locator",
78                ));
79            }
80        };
81        let lock_path = dirs
82            .lock_dir(&identity.tool)
83            .join(format!("{component}.lock"));
84        let scratch_root = dirs
85            .tmp()
86            .join(sanitize_tool_id(&identity.tool))
87            .join(sanitize_version_component(&identity.version))
88            .join(component);
89        Ok(Self {
90            identity,
91            installs_root: dirs.installs.clone(),
92            install_root,
93            legacy_install_root,
94            legacy_global_install_root,
95            lock_path,
96            scratch_root,
97        })
98    }
99
100    pub fn identity(&self) -> &InstallIdentity {
101        &self.identity
102    }
103
104    pub fn install_root(&self) -> &Path {
105        &self.install_root
106    }
107
108    pub(crate) fn installs_root(&self) -> &Path {
109        &self.installs_root
110    }
111
112    pub fn legacy_install_root(&self) -> &Path {
113        &self.legacy_install_root
114    }
115
116    pub fn legacy_global_install_root(&self) -> Option<&Path> {
117        self.legacy_global_install_root.as_deref()
118    }
119
120    pub fn lock_path(&self) -> &Path {
121        &self.lock_path
122    }
123
124    pub fn scratch_root(&self) -> &Path {
125        &self.scratch_root
126    }
127
128    /// Require a discovered manifest to reside at this identity's canonical
129    /// fingerprinted root, with no links inside the osdk-managed tree.
130    pub fn validates_install_root(&self, root: &Path) -> bool {
131        paths_agree(&self.install_root, root)
132            && path_has_no_symlink_directories_from(&self.installs_root, root, true)
133    }
134
135    /// Require the canonical identity root to exist entirely as real
136    /// directories. This is the filesystem trust check for consumers that are
137    /// about to execute or remove content from an install.
138    pub fn validates_existing_install_root(&self, root: &Path) -> bool {
139        paths_agree(&self.install_root, root)
140            && path_has_no_symlink_directories_from(&self.installs_root, root, false)
141    }
142
143    /// Validate a scanned root using only the configured installs directory.
144    pub fn is_canonical_install_root(
145        installs: &Path,
146        identity: &InstallIdentity,
147        root: &Path,
148    ) -> Result<bool> {
149        identity.validate()?;
150        let component = install_id_component(&identity.install_id)?;
151        let base_tool = match identity.scope {
152            InstallScope::Isolated => identity.tool.clone(),
153            InstallScope::Global => {
154                let package = identity.tool.strip_prefix("npm:").ok_or_else(|| {
155                    Error::config("global dynamic installs are supported only for npm")
156                })?;
157                format!("npm-global:{package}")
158            }
159            InstallScope::ProjectManaged => {
160                return Err(Error::config(
161                    "project-managed tools do not have an osdk install locator",
162                ));
163            }
164        };
165        let expected = installs
166            .join(sanitize_tool_id(&base_tool))
167            .join(sanitize_version_component(&identity.version))
168            .join(component);
169        Ok(paths_agree(&expected, root)
170            && path_has_no_symlink_directories_from(installs, root, false))
171    }
172}
173
174/// Compare a caller-supplied install root with the root derived from trusted
175/// identity fields. The managed path is checked separately from its platform
176/// ancestors: macOS exposes temporary directories below the `/var` alias and
177/// Windows may return an equivalent long path for an 8.3 path.
178fn paths_agree(expected: &Path, actual: &Path) -> bool {
179    expected == actual
180}
181
182fn path_has_no_symlink_directories_from(base: &Path, path: &Path, allow_missing: bool) -> bool {
183    let Ok(relative) = path.strip_prefix(base) else {
184        return false;
185    };
186    let mut current = base.to_path_buf();
187    let metadata = match std::fs::symlink_metadata(&current) {
188        Ok(metadata) => metadata,
189        Err(error) if allow_missing && error.kind() == std::io::ErrorKind::NotFound => return true,
190        Err(_) => return false,
191    };
192    if metadata.file_type().is_symlink() || !metadata.is_dir() {
193        return false;
194    }
195
196    for component in relative.components() {
197        match component {
198            std::path::Component::Normal(_) => {
199                current.push(component.as_os_str());
200                let metadata = match std::fs::symlink_metadata(&current) {
201                    Ok(metadata) => metadata,
202                    Err(error) if allow_missing && error.kind() == std::io::ErrorKind::NotFound => {
203                        return true;
204                    }
205                    Err(_) => return false,
206                };
207                if metadata.file_type().is_symlink() || !metadata.is_dir() {
208                    return false;
209                }
210            }
211            _ => return false,
212        }
213    }
214    true
215}
216
217pub fn install_id_component(install_id: &str) -> Result<String> {
218    let Some(digest) = install_id.strip_prefix("b3-v2:") else {
219        return Err(Error::config("dynamic install id must use b3-v2"));
220    };
221    if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
222        return Err(Error::config(
223            "dynamic install id contains an invalid BLAKE3 digest",
224        ));
225    }
226    Ok(format!("b3-v2-{}", digest.to_ascii_lowercase()))
227}
228
229impl Dirs {
230    /// Resolve directories from env overrides + platform defaults.
231    pub fn resolve() -> Result<Dirs> {
232        Self::resolve_from(|k| std::env::var(k).ok())
233    }
234
235    /// Resolve using a custom env lookup (used by tests).
236    pub fn resolve_from(getenv: impl Fn(&str) -> Option<String>) -> Result<Dirs> {
237        let proj = directories::ProjectDirs::from("", "", "osdk");
238
239        let data = match getenv(env_keys::DATA_DIR) {
240            Some(v) => PathBuf::from(v),
241            None => proj
242                .as_ref()
243                .map(|p| p.data_dir().to_path_buf())
244                .ok_or_else(|| Error::config("cannot determine data dir; set OSDK_DATA_DIR"))?,
245        };
246        let cache = match getenv(env_keys::CACHE_DIR) {
247            Some(v) => PathBuf::from(v),
248            None => proj
249                .as_ref()
250                .map(|p| p.cache_dir().to_path_buf())
251                .ok_or_else(|| Error::config("cannot determine cache dir; set OSDK_CACHE_DIR"))?,
252        };
253        let config = match getenv(env_keys::CONFIG_DIR) {
254            Some(v) => PathBuf::from(v),
255            None => proj
256                .as_ref()
257                .map(|p| p.config_dir().to_path_buf())
258                .ok_or_else(|| Error::config("cannot determine config dir; set OSDK_CONFIG_DIR"))?,
259        };
260
261        let store = getenv(env_keys::STORE_DIR)
262            .map(PathBuf::from)
263            .unwrap_or_else(|| data.join("store"));
264        let installs = getenv(env_keys::INSTALL_DIR)
265            .map(PathBuf::from)
266            .unwrap_or_else(|| data.join("installs"));
267
268        Ok(Dirs {
269            data,
270            cache,
271            config,
272            store,
273            installs,
274        })
275    }
276
277    pub fn shims(&self) -> PathBuf {
278        self.data.join("shims")
279    }
280    pub fn plugins(&self) -> PathBuf {
281        self.data.join("plugins")
282    }
283    /// Materialized model snapshots and per-model current-revision markers.
284    pub fn models(&self) -> PathBuf {
285        self.data.join("models")
286    }
287    /// Self-contained rustup home for the delegate rust backend.
288    pub fn rustup_home(&self) -> PathBuf {
289        self.data.join("rustup")
290    }
291    /// Self-contained cargo home for the delegate rust backend.
292    pub fn cargo_home(&self) -> PathBuf {
293        self.data.join("cargo")
294    }
295
296    pub fn downloads(&self) -> PathBuf {
297        self.cache.join("downloads")
298    }
299    pub fn tmp(&self) -> PathBuf {
300        self.cache.join("tmp")
301    }
302    /// Cached remote indices (version lists) with TTL.
303    pub fn remote_cache(&self) -> PathBuf {
304        self.cache.join("remote")
305    }
306    /// Cached source speed-probe results with TTL.
307    pub fn sources_cache(&self) -> PathBuf {
308        self.cache.join("sources")
309    }
310
311    /// Install directory for a specific tool version.
312    pub fn install_path(&self, tool: &str, version: &str) -> PathBuf {
313        self.installs
314            .join(sanitize_tool_id(tool))
315            .join(sanitize_version_component(version))
316    }
317
318    /// Directory holding per-version install locks for a tool.
319    pub fn lock_dir(&self, tool: &str) -> PathBuf {
320        self.installs.join(sanitize_tool_id(tool)).join(".locks")
321    }
322
323    pub fn user_config_file(&self) -> PathBuf {
324        self.config.join("config.toml")
325    }
326
327    /// User-scoped lockfile. Keeping it beside `config.toml` gives global tool
328    /// pins a deterministic lock independent of the current working directory.
329    pub fn user_lock_file(&self) -> PathBuf {
330        self.config.join("osdk.lock")
331    }
332
333    /// Create the core directory tree (idempotent).
334    pub fn ensure(&self) -> Result<()> {
335        for d in [
336            &self.data,
337            &self.cache,
338            &self.config,
339            &self.store,
340            &self.installs,
341            &self.models(),
342            &self.shims(),
343            &self.downloads(),
344            &self.tmp(),
345            &self.remote_cache(),
346            &self.sources_cache(),
347        ] {
348            create_dir_all(d)?;
349        }
350        Ok(())
351    }
352}
353
354const ENCODED_VERSION_PREFIX: &str = "~v1~";
355
356/// Encode a version label as one collision-resistant, portable filesystem
357/// component. Common lowercase semver labels remain readable. Other labels use
358/// a self-identifying prefix followed by percent-encoded UTF-8 bytes, avoiding
359/// ambiguity with legacy names that contain literal percent escapes.
360pub fn sanitize_version_component(version: &str) -> String {
361    let portable = !version.is_empty()
362        && version.bytes().all(|byte| {
363            byte.is_ascii_lowercase()
364                || byte.is_ascii_digit()
365                || matches!(byte, b'.' | b'-' | b'_' | b'+')
366        })
367        && version != "."
368        && version != ".."
369        && !version.ends_with('.')
370        && !is_windows_reserved_name(version);
371    if portable {
372        return version.to_string();
373    }
374
375    let mut out = String::with_capacity(ENCODED_VERSION_PREFIX.len() + version.len() * 3);
376    out.push_str(ENCODED_VERSION_PREFIX);
377    for byte in version.bytes() {
378        use std::fmt::Write as _;
379        write!(&mut out, "%{byte:02X}").expect("writing to String cannot fail");
380    }
381    out
382}
383
384/// Decode a component produced by [`sanitize_version_component`]. Unprefixed,
385/// malformed, non-UTF-8, and non-canonical values are treated as legacy names
386/// and returned unchanged.
387pub fn decode_version_component(component: &str) -> String {
388    let Some(encoded) = component.strip_prefix(ENCODED_VERSION_PREFIX) else {
389        return component.to_string();
390    };
391    if encoded.len() % 3 != 0 {
392        return component.to_string();
393    }
394    let bytes = encoded.as_bytes();
395    let mut decoded = Vec::with_capacity(bytes.len() / 3);
396    for chunk in bytes.as_chunks::<3>().0 {
397        if chunk[0] != b'%' {
398            return component.to_string();
399        }
400        let Some(high) = hex_value(chunk[1]) else {
401            return component.to_string();
402        };
403        let Some(low) = hex_value(chunk[2]) else {
404            return component.to_string();
405        };
406        decoded.push(high * 16 + low);
407    }
408    let Ok(decoded) = String::from_utf8(decoded) else {
409        return component.to_string();
410    };
411    if sanitize_version_component(&decoded) == component {
412        decoded
413    } else {
414        component.to_string()
415    }
416}
417
418fn hex_value(byte: u8) -> Option<u8> {
419    match byte {
420        b'0'..=b'9' => Some(byte - b'0'),
421        b'A'..=b'F' => Some(byte - b'A' + 10),
422        _ => None,
423    }
424}
425
426fn is_windows_reserved_name(value: &str) -> bool {
427    let stem = value.split('.').next().unwrap_or_default();
428    matches!(stem, "con" | "prn" | "aux" | "nul")
429        || stem.strip_prefix("com").is_some_and(|suffix| {
430            matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
431        })
432        || stem.strip_prefix("lpt").is_some_and(|suffix| {
433            matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
434        })
435}
436
437pub(crate) fn create_dir_all(p: &Path) -> Result<()> {
438    std::fs::create_dir_all(p).map_err(|e| Error::io(p, e))
439}
440
441/// Map a (possibly namespaced) tool id to a filesystem-safe nested path
442/// component. e.g. `github:owner/repo` -> `github/owner/repo`. `:` is replaced
443/// (invalid on Windows) and path traversal is neutralized.
444pub fn sanitize_tool_id(tool: &str) -> PathBuf {
445    let mut out = PathBuf::new();
446    for part in tool.split([':', '/', '\\']) {
447        let part = part.trim();
448        if part.is_empty() || part == "." || part == ".." {
449            continue;
450        }
451        out.push(part);
452    }
453    if out.as_os_str().is_empty() {
454        out.push("_");
455    }
456    out
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use std::collections::HashMap;
463
464    #[test]
465    fn env_overrides_win() {
466        let mut env = HashMap::new();
467        env.insert(env_keys::DATA_DIR.to_string(), "/x/data".to_string());
468        env.insert(env_keys::CACHE_DIR.to_string(), "/x/cache".to_string());
469        env.insert(env_keys::CONFIG_DIR.to_string(), "/x/cfg".to_string());
470        let d = Dirs::resolve_from(|k| env.get(k).cloned()).unwrap();
471        assert_eq!(d.data, PathBuf::from("/x/data"));
472        assert_eq!(d.store, PathBuf::from("/x/data/store"));
473        assert_eq!(d.installs, PathBuf::from("/x/data/installs"));
474        assert_eq!(d.shims(), PathBuf::from("/x/data/shims"));
475        assert_eq!(d.models(), PathBuf::from("/x/data/models"));
476        assert_eq!(d.user_lock_file(), PathBuf::from("/x/cfg/osdk.lock"));
477        assert_eq!(
478            d.install_path("node", "20.1.0"),
479            PathBuf::from("/x/data/installs/node/20.1.0")
480        );
481    }
482
483    #[test]
484    fn store_dir_can_be_split_off() {
485        let mut env = HashMap::new();
486        env.insert(env_keys::DATA_DIR.to_string(), "/x/data".to_string());
487        env.insert(env_keys::CACHE_DIR.to_string(), "/x/cache".to_string());
488        env.insert(env_keys::CONFIG_DIR.to_string(), "/x/cfg".to_string());
489        env.insert(env_keys::STORE_DIR.to_string(), "/big/store".to_string());
490        let d = Dirs::resolve_from(|k| env.get(k).cloned()).unwrap();
491        assert_eq!(d.store, PathBuf::from("/big/store"));
492    }
493
494    #[test]
495    fn version_components_cannot_escape_install_root() {
496        assert_eq!(sanitize_version_component("20.1.0"), "20.1.0");
497        assert_eq!(
498            sanitize_version_component("../../victim"),
499            "~v1~%2E%2E%2F%2E%2E%2F%76%69%63%74%69%6D"
500        );
501        assert_eq!(sanitize_version_component(".."), "~v1~%2E%2E");
502    }
503
504    #[test]
505    fn version_encoding_is_collision_resistant_and_portable() {
506        let values = [
507            "release/2026",
508            "release_2026",
509            r"release\2026",
510            "release%2F2026",
511            "Release/2026",
512            "con",
513            "con.txt",
514            "version.",
515        ];
516        let encoded = values
517            .iter()
518            .map(|value| sanitize_version_component(value))
519            .collect::<std::collections::BTreeSet<_>>();
520        assert_eq!(encoded.len(), values.len());
521        assert!(encoded.iter().all(|value| !value.contains(['/', '\\'])));
522    }
523
524    #[test]
525    fn version_encoding_round_trips_without_literal_percent_ambiguity() {
526        for version in [
527            "20.1.0",
528            "release/2026",
529            "Release-2026",
530            r"release\2026",
531            "release%2F2026",
532            "版本/二〇二六",
533            "",
534        ] {
535            assert_eq!(
536                decode_version_component(&sanitize_version_component(version)),
537                version
538            );
539        }
540        for legacy in ["release%2F2026", "~v1~broken", "~v1~%FF", "~v1~%2f"] {
541            assert_eq!(decode_version_component(legacy), legacy);
542        }
543    }
544
545    #[test]
546    fn dynamic_locator_is_fingerprinted_and_keeps_legacy_root_explicit() {
547        let mut env = HashMap::new();
548        env.insert(env_keys::DATA_DIR.to_string(), "/x/data".to_string());
549        env.insert(env_keys::CACHE_DIR.to_string(), "/x/cache".to_string());
550        env.insert(env_keys::CONFIG_DIR.to_string(), "/x/config".to_string());
551        let dirs = Dirs::resolve_from(|key| env.get(key).cloned()).unwrap();
552        let identity = crate::tool::InstallIdentity::new(
553            "npm:prettier",
554            "3.6.2",
555            "linux-x64",
556            crate::tool::InstallScope::Global,
557            &std::collections::BTreeMap::new(),
558            Vec::new(),
559            std::collections::BTreeMap::new(),
560        )
561        .unwrap();
562        let locator = InstallLocator::new(&dirs, identity.clone()).unwrap();
563        assert_eq!(locator.identity(), &identity);
564        assert_eq!(
565            locator.install_root().parent(),
566            Some(Path::new("/x/data/installs/npm-global/prettier/3.6.2"))
567        );
568        assert_eq!(
569            locator.legacy_global_install_root(),
570            Some(Path::new("/x/data/installs/npm-global/prettier/3.6.2"))
571        );
572        let leaf = locator
573            .install_root()
574            .file_name()
575            .unwrap()
576            .to_string_lossy();
577        assert!(leaf.starts_with("b3-v2-"));
578        assert!(!leaf.contains(':'));
579        assert!(locator.lock_path().ends_with(format!("{leaf}.lock")));
580        assert!(locator
581            .scratch_root()
582            .ends_with(Path::new(leaf.as_ref() as &str)));
583        assert!(locator.validates_install_root(locator.install_root()));
584        assert!(!locator.validates_install_root(locator.legacy_install_root()));
585
586        let unsupported = crate::tool::InstallIdentity::new(
587            "github:cli/cli",
588            "2.0.0",
589            "linux-x64",
590            crate::tool::InstallScope::Global,
591            &std::collections::BTreeMap::new(),
592            Vec::new(),
593            std::collections::BTreeMap::new(),
594        )
595        .unwrap();
596        assert!(InstallLocator::new(&dirs, unsupported).is_err());
597    }
598
599    #[test]
600    fn dynamic_locator_rejects_changed_and_aliased_roots() {
601        let temporary = tempfile::tempdir().unwrap();
602        let data = temporary.path().join("data");
603        let cache = temporary.path().join("cache");
604        let config = temporary.path().join("config");
605        let dirs = Dirs {
606            store: data.join("store"),
607            installs: data.join("installs"),
608            data,
609            cache,
610            config,
611        };
612        let identity = crate::tool::InstallIdentity::new(
613            "npm:prettier",
614            "3.6.2",
615            "linux-x64",
616            crate::tool::InstallScope::Isolated,
617            &std::collections::BTreeMap::new(),
618            Vec::new(),
619            std::collections::BTreeMap::new(),
620        )
621        .unwrap();
622        let locator = InstallLocator::new(&dirs, identity.clone()).unwrap();
623        assert!(!locator.validates_install_root(&locator.install_root().join("..")));
624        assert!(!InstallLocator::is_canonical_install_root(
625            &dirs.installs,
626            &identity,
627            &dirs.installs.join("changed")
628        )
629        .unwrap());
630
631        std::fs::create_dir_all(locator.install_root()).unwrap();
632        assert!(locator.validates_install_root(locator.install_root()));
633        assert!(locator.validates_existing_install_root(locator.install_root()));
634    }
635
636    #[cfg(unix)]
637    #[test]
638    fn dynamic_locator_rejects_a_symlinked_expected_root() {
639        use std::os::unix::fs::symlink;
640
641        let temporary = tempfile::tempdir().unwrap();
642        let data = temporary.path().join("data");
643        let dirs = Dirs {
644            store: data.join("store"),
645            installs: data.join("installs"),
646            data,
647            cache: temporary.path().join("cache"),
648            config: temporary.path().join("config"),
649        };
650        let identity = crate::tool::InstallIdentity::new(
651            "npm:prettier",
652            "3.6.2",
653            "linux-x64",
654            crate::tool::InstallScope::Isolated,
655            &std::collections::BTreeMap::new(),
656            Vec::new(),
657            std::collections::BTreeMap::new(),
658        )
659        .unwrap();
660        let locator = InstallLocator::new(&dirs, identity.clone()).unwrap();
661        let outside = temporary.path().join("outside");
662        std::fs::create_dir(&outside).unwrap();
663        std::fs::create_dir_all(locator.install_root().parent().unwrap()).unwrap();
664        symlink(&outside, locator.install_root()).unwrap();
665
666        assert!(!locator.validates_install_root(locator.install_root()));
667        assert!(!locator.validates_existing_install_root(locator.install_root()));
668        assert!(!InstallLocator::is_canonical_install_root(
669            &dirs.installs,
670            &identity,
671            locator.install_root()
672        )
673        .unwrap());
674    }
675
676    #[cfg(unix)]
677    #[test]
678    fn dynamic_locator_accepts_a_real_root_below_a_platform_alias() {
679        use std::os::unix::fs::symlink;
680
681        let temporary = tempfile::tempdir().unwrap();
682        let real_data = temporary.path().join("real");
683        std::fs::create_dir(&real_data).unwrap();
684        let alias = temporary.path().join("alias");
685        symlink(&real_data, &alias).unwrap();
686        let dirs = Dirs {
687            store: alias.join("store"),
688            installs: alias.join("installs"),
689            data: alias.clone(),
690            cache: alias.join("cache"),
691            config: alias.join("config"),
692        };
693        let identity = crate::tool::InstallIdentity::new(
694            "npm:prettier",
695            "3.6.2",
696            "linux-x64",
697            crate::tool::InstallScope::Isolated,
698            &std::collections::BTreeMap::new(),
699            Vec::new(),
700            std::collections::BTreeMap::new(),
701        )
702        .unwrap();
703        let locator = InstallLocator::new(&dirs, identity).unwrap();
704        std::fs::create_dir_all(locator.install_root()).unwrap();
705
706        assert!(locator.validates_install_root(locator.install_root()));
707        assert!(locator.validates_existing_install_root(locator.install_root()));
708    }
709}