1use std::path::{Path, PathBuf};
20
21use crate::error::{Error, Result};
22use crate::tool::{InstallIdentity, InstallScope};
23
24pub 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 pub data: PathBuf,
37 pub cache: PathBuf,
39 pub config: PathBuf,
41 pub store: PathBuf,
44 pub installs: PathBuf,
46}
47
48#[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 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 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 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
174fn 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(¤t) {
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(¤t) {
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 pub fn resolve() -> Result<Dirs> {
232 Self::resolve_from(|k| std::env::var(k).ok())
233 }
234
235 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 pub fn models(&self) -> PathBuf {
285 self.data.join("models")
286 }
287 pub fn rustup_home(&self) -> PathBuf {
289 self.data.join("rustup")
290 }
291 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 pub fn remote_cache(&self) -> PathBuf {
304 self.cache.join("remote")
305 }
306 pub fn sources_cache(&self) -> PathBuf {
308 self.cache.join("sources")
309 }
310
311 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 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 pub fn user_lock_file(&self) -> PathBuf {
330 self.config.join("osdk.lock")
331 }
332
333 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
356pub 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
384pub 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
441pub 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}