1use std::collections::{BTreeMap, BTreeSet};
14use std::path::Path;
15
16use crate::backend::{Backend, Ctx};
17use crate::dirs::{create_dir_all, Dirs};
18use crate::error::{Error, Result};
19use crate::inventory::{self, BinOwnerCandidate, DynamicToolManifest, ScanOptions, ScanReport};
20use crate::npm_tools::{ToolScope, LOCKED_NPM_SCOPE_OPTION};
21use crate::version::ToolVersion;
22use crate::version::{ToolRequest, VersionSpec};
23
24pub fn routed_bin_names(
29 ctx: &Ctx,
30 backend: &dyn Backend,
31 version: &ToolVersion,
32) -> Result<Vec<String>> {
33 let mut names = backend
34 .bin_names(ctx, version)?
35 .into_iter()
36 .collect::<BTreeSet<_>>();
37 if backend.id() == "node" {
38 for name in crate::backend::bin_names_in_dirs(&backend.bin_paths(ctx, version)?) {
39 if matches!(name.as_str(), "npm" | "npx") {
40 names.insert(name);
41 }
42 }
43 }
44 Ok(names.into_iter().collect())
45}
46
47pub fn scan_dynamic_installs(ctx: &Ctx) -> Result<ScanReport> {
49 inventory::scan_installs(&ctx.dirs.installs, &ScanOptions::default())
50}
51
52pub fn configured_dynamic_ids(ctx: &Ctx, _report: &ScanReport) -> Vec<String> {
55 let mut ids = BTreeSet::new();
56 ids.extend(ctx.config.tools.iter().filter_map(|(key, value)| {
57 inventory::canonical_dynamic_id(key).ok().or_else(|| {
58 ToolRequest::parse(value)
59 .ok()
60 .filter(|request| request.backend.contains(':'))
61 .map(|request| request.backend)
62 })
63 }));
64
65 ids.into_iter().collect()
66}
67
68pub fn configured_and_installed_dynamic_ids(ctx: &Ctx, report: &ScanReport) -> Vec<String> {
71 let mut ids = BTreeSet::new();
72 ids.extend(configured_dynamic_ids(ctx, report));
73 ids.extend(report.installed_ids());
74 ids.into_iter().collect()
75}
76
77pub fn dynamic_request_from_config(ctx: &Ctx, backend_id: &str) -> Option<ToolRequest> {
81 if !backend_id.contains(':') {
82 return None;
83 }
84 for (key, value) in &ctx.config.tools {
85 if inventory::canonical_dynamic_id(key).ok().as_deref() == Some(backend_id) {
86 return Some(ToolRequest {
87 backend: backend_id.to_string(),
88 spec: VersionSpec::parse(value),
89 options: ctx
90 .config
91 .tool_configs
92 .get(key)
93 .map(|entry| entry.to_request_options())
94 .unwrap_or_default(),
95 });
96 }
97 if let Ok(mut request) = ToolRequest::parse(value) {
98 if request.backend == backend_id {
99 if let Some(entry) = ctx.config.tool_configs.get(key) {
100 request.options.extend(entry.to_request_options());
101 }
102 return Some(request);
103 }
104 }
105 }
106 None
107}
108
109#[derive(Debug)]
113pub struct ValidatedDynamicInstall {
114 install_root: std::path::PathBuf,
115 bins: BTreeMap<String, std::path::PathBuf>,
116 identity: crate::tool::InstallIdentity,
117}
118
119impl ValidatedDynamicInstall {
120 pub fn install_root(&self) -> &Path {
121 &self.install_root
122 }
123
124 pub fn bin_names(&self) -> Vec<String> {
125 self.bins.keys().cloned().collect()
126 }
127
128 pub fn bin_paths(&self) -> Vec<std::path::PathBuf> {
129 self.bins
130 .values()
131 .filter_map(|path| path.parent().map(Path::to_path_buf))
132 .collect::<BTreeSet<_>>()
133 .into_iter()
134 .collect()
135 }
136
137 pub fn executable(&self, name: &str) -> Option<std::path::PathBuf> {
138 self.bins.get(name).cloned()
139 }
140
141 pub fn identity(&self) -> &crate::tool::InstallIdentity {
142 &self.identity
143 }
144}
145
146pub fn validated_dynamic_install(
151 ctx: &Ctx,
152 report: &ScanReport,
153 request: &ToolRequest,
154 version: &str,
155) -> Result<ValidatedDynamicInstall> {
156 let backend = crate::backend::registry::Registry::load(&ctx.dirs)?.get(&request.backend)?;
157 let (identity, root) =
158 selected_dynamic_install_from_report(ctx, report, backend.as_ref(), request, version)?;
159 let locator = crate::dirs::InstallLocator::new(&ctx.dirs, identity.clone())?;
160 if !std::fs::symlink_metadata(root.join(".osdk-complete"))
161 .is_ok_and(|metadata| metadata.file_type().is_file())
162 {
163 return Err(Error::other(format!(
164 "dynamic tool `{}@{version}` has no complete selected install; reinstall it before use",
165 request.backend
166 )));
167 }
168 let manifest_path = DynamicToolManifest::manifest_path(&root);
169 let install = report
170 .installs
171 .iter()
172 .find(|install| install.install_root == root)
173 .ok_or_else(|| {
174 Error::other(format!(
175 "dynamic tool `{}@{version}` has missing or invalid install identity at {}; reinstall it before use",
176 request.backend,
177 manifest_path.display()
178 ))
179 })?;
180 install.revalidate()?;
181 let manifest = &install.manifest;
182 if !manifest.matches_identity(&identity) || !locator.validates_install_root(&root) {
183 return Err(Error::other(format!(
184 "dynamic tool `{}@{version}` was installed with a different identity; reinstall it before use",
185 request.backend
186 )));
187 }
188 let mut selected = ToolVersion::new(&request.backend, version);
189 selected.options = request.options.clone();
190 if !backend.validate_dynamic_install(ctx, &selected, &root, &manifest.identity)? {
191 return Err(Error::other(format!(
192 "dynamic tool `{}@{version}` has invalid provider evidence; reinstall it before use",
193 request.backend
194 )));
195 }
196 let canonical_root = dunce::canonicalize(&root).map_err(|error| Error::io(&root, error))?;
197 let mut bins = BTreeMap::new();
198 for bin in &manifest.bins {
199 let path = root.join(&bin.path);
200 let canonical = dunce::canonicalize(&path).map_err(|error| Error::io(&path, error))?;
201 if !canonical.is_file() || !canonical.starts_with(&canonical_root) {
202 return Err(Error::other(format!(
203 "dynamic tool `{}` inventory bin `{}` does not resolve to a file inside {}",
204 request.backend,
205 bin.name,
206 root.display()
207 )));
208 }
209 bins.insert(bin.name.clone(), canonical);
210 }
211 Ok(ValidatedDynamicInstall {
212 install_root: root,
213 bins,
214 identity,
215 })
216}
217
218pub fn dynamic_bin_ownership(report: &ScanReport) -> BTreeMap<String, Vec<BinOwnerCandidate>> {
222 inventory::build_bin_ownership_candidates(&report.installs)
223}
224
225pub fn selected_dynamic_install_identity(
226 ctx: &Ctx,
227 backend: &dyn Backend,
228 request: &ToolRequest,
229 version: &str,
230) -> Result<crate::tool::InstallIdentity> {
231 let mut tv = ToolVersion::new(&request.backend, version);
232 tv.options = request.options.clone();
233 backend.dynamic_install_identity(ctx, &tv)?.ok_or_else(|| {
234 Error::other(format!(
235 "dynamic tool `{}@{version}` requires installed identity discovery",
236 request.backend
237 ))
238 })
239}
240
241fn selected_dynamic_install_from_report(
242 ctx: &Ctx,
243 report: &ScanReport,
244 backend: &dyn Backend,
245 request: &ToolRequest,
246 version: &str,
247) -> Result<(crate::tool::InstallIdentity, std::path::PathBuf)> {
248 let mut selected = ToolVersion::new(&request.backend, version);
249 selected.options = request.options.clone();
250 if backend.dynamic_install_identity(ctx, &selected)?.is_none() {
251 let scope = crate::tool::InstallScope::Isolated;
252 let expected_options = crate::tool::dynamic_identity_options(
253 &crate::tool::ToolId::parse(&request.backend)?,
254 &request.options,
255 )?
256 .into_map();
257 let mut matching = report.installs.iter().filter(|install| {
258 let identity = &install.manifest.identity;
259 identity.tool == request.backend
260 && identity.version == version
261 && identity.platform == ctx.platform.to_string()
262 && identity.scope == scope
263 && identity.material_options == expected_options
264 && std::fs::symlink_metadata(install.install_root.join(".osdk-complete"))
265 .is_ok_and(|metadata| metadata.file_type().is_file())
266 && backend
267 .validate_dynamic_install(ctx, &selected, &install.install_root, identity)
268 .unwrap_or(false)
269 });
270 let first = matching.next();
271 if matching.next().is_some() {
272 return Err(Error::other(format!(
273 "dynamic tool `{}@{version}` has multiple complete installs matching its unlocked request; use a lockfile with exact artifact identity or reinstall it",
274 request.backend
275 )));
276 }
277 if let Some(install) = first {
278 return Ok((
279 install.manifest.identity.clone(),
280 install.install_root.clone(),
281 ));
282 }
283 return Err(Error::other(format!(
284 "dynamic tool `{}@{version}` has no complete install matching its unlocked request; reinstall it before use",
285 request.backend
286 )));
287 }
288 let identity = selected_dynamic_install_identity(ctx, backend, request, version)?;
289 let root = crate::dirs::InstallLocator::new(&ctx.dirs, identity.clone())?
290 .install_root()
291 .to_path_buf();
292 Ok((identity, root))
293}
294
295pub(crate) fn configured_npm_scope(ctx: &Ctx, request: &ToolRequest) -> Result<Option<ToolScope>> {
296 if let Some(scope) = request.options.get(LOCKED_NPM_SCOPE_OPTION) {
297 return scope.parse().map(Some);
298 }
299 let mut project = false;
300 let mut global = false;
301 for (key, value) in &ctx.config.tools {
302 let matches = key == &request.backend
303 || ToolRequest::parse(value)
304 .is_ok_and(|candidate| candidate.backend == request.backend);
305 if !matches {
306 continue;
307 }
308 match ctx.config.tool_origins.get(key) {
309 Some(
310 crate::config::ToolConfigOrigin::ProjectConfig(_)
311 | crate::config::ToolConfigOrigin::ToolVersions(_),
312 ) => project = true,
313 Some(crate::config::ToolConfigOrigin::GlobalConfig(_)) => global = true,
314 None if ctx.config.global_tool_configs.contains_key(key) => global = true,
315 None => {}
316 }
317 }
318 Ok(if project {
319 Some(ToolScope::Project)
320 } else if global {
321 Some(ToolScope::Global)
322 } else {
323 None
324 })
325}
326
327pub fn generate_shim(dirs: &Dirs, name: &str, osdk_shim_bin: &Path) -> Result<()> {
329 let shims = dirs.shims();
330 create_dir_all(&shims)?;
331 generate_shim_in(&shims, name, osdk_shim_bin)
332}
333
334#[cfg(unix)]
335fn generate_shim_in(shims: &Path, name: &str, osdk_shim_bin: &Path) -> Result<()> {
336 create_dir_all(shims)?;
337 let link = shims.join(name);
338 let _ = std::fs::remove_file(&link);
339 std::os::unix::fs::symlink(osdk_shim_bin, &link).map_err(|e| Error::io(&link, e))?;
340 Ok(())
341}
342
343#[cfg(windows)]
344fn generate_shim_in(shims: &Path, name: &str, osdk_shim_bin: &Path) -> Result<()> {
345 create_dir_all(shims)?;
346 let cmd_path = shims.join(format!("{name}.cmd"));
348 let cmd = format!("@echo off\r\n\"{}\" %~n0 %*\r\n", osdk_shim_bin.display());
349 std::fs::write(&cmd_path, cmd).map_err(|e| Error::io(&cmd_path, e))?;
350
351 let sh_path = shims.join(name);
353 let sh = format!(
354 "#!/bin/sh\nexec \"{}\" \"$(basename \"$0\")\" \"$@\"\n",
355 osdk_shim_bin.display().to_string().replace('\\', "/")
356 );
357 std::fs::write(&sh_path, sh).map_err(|e| Error::io(&sh_path, e))?;
358 Ok(())
359}
360
361pub fn remove_shim(dirs: &Dirs, name: &str) -> Result<()> {
363 let shims = dirs.shims();
364 let _ = std::fs::remove_file(shims.join(name));
365 #[cfg(windows)]
366 {
367 let _ = std::fs::remove_file(shims.join(format!("{name}.cmd")));
368 }
369 Ok(())
370}
371
372pub fn remove_managed_shim(dirs: &Dirs, name: &str) -> Result<bool> {
376 let shims = dirs.shims();
377 let removed = remove_managed_shim_path(&shims.join(name))?;
378 #[cfg(windows)]
379 {
380 let cmd_removed = remove_managed_shim_path(&shims.join(format!("{name}.cmd")))?;
381 Ok(removed || cmd_removed)
382 }
383 #[cfg(not(windows))]
384 Ok(removed)
385}
386
387#[cfg(unix)]
388fn remove_managed_shim_path(path: &Path) -> Result<bool> {
389 let metadata = match std::fs::symlink_metadata(path) {
390 Ok(metadata) => metadata,
391 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
392 Err(error) => return Err(Error::io(path, error)),
393 };
394 if !metadata.file_type().is_symlink() {
395 return Ok(false);
396 }
397 let target = std::fs::read_link(path).map_err(|error| Error::io(path, error))?;
398 if target.file_name().and_then(|name| name.to_str()) != Some("osdk-shim") {
399 return Ok(false);
400 }
401 std::fs::remove_file(path).map_err(|error| Error::io(path, error))?;
402 Ok(true)
403}
404
405#[cfg(windows)]
406fn remove_managed_shim_path(path: &Path) -> Result<bool> {
407 let contents = match std::fs::read_to_string(path) {
408 Ok(contents) => contents,
409 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
410 Err(error) => return Err(Error::io(path, error)),
411 };
412 let lower = contents.to_ascii_lowercase();
413 let generated_cmd =
414 lower.starts_with("@echo off\r\n\"") && lower.contains("osdk-shim.exe\" %~n0 %*");
415 let generated_shell = lower.starts_with("#!/bin/sh\nexec \"")
416 && lower.contains("osdk-shim.exe\" \"$(basename \"$0\")\" \"$@\"");
417 if !generated_cmd && !generated_shell {
418 return Ok(false);
419 }
420 std::fs::remove_file(path).map_err(|error| Error::io(path, error))?;
421 Ok(true)
422}
423
424pub fn find_shim_binary(dirs: &Dirs) -> Option<std::path::PathBuf> {
427 let exe_suffix = if cfg!(windows) { ".exe" } else { "" };
428 let name = format!("osdk-shim{exe_suffix}");
429 if let Ok(current) = std::env::current_exe() {
430 if let Some(parent) = current.parent() {
431 let candidate = parent.join(&name);
432 if candidate.exists() {
433 return Some(candidate);
434 }
435 }
436 }
437 let candidate = dirs.data.join("bin").join(&name);
438 if candidate.exists() {
439 return Some(candidate);
440 }
441 None
442}
443
444#[cfg(test)]
445mod tests {
446 use std::collections::BTreeMap;
447 use std::path::PathBuf;
448 use std::sync::Arc;
449
450 use super::*;
451 use crate::backend::npm_package::NpmPackageBackend;
452 use crate::config::{Config, Settings, SourcesConfig, ToolConfigEntry, ToolConfigOrigin};
453 use crate::inventory::DynamicToolBin;
454 use crate::platform::Platform;
455 use crate::store::Cas;
456 use crate::tool::{InstallDependency, InstallDependencyKind, InstallScope};
457
458 fn npm_scope_test_ctx(
459 root: &Path,
460 origin: Option<ToolConfigOrigin>,
461 ) -> (Ctx, NpmPackageBackend) {
462 let dirs = Dirs::resolve_from(|key| match key {
463 "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
464 "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
465 "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
466 "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
467 "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
468 _ => None,
469 })
470 .unwrap();
471 let key = "npm:fixture-cli".to_string();
472 let entry = ToolConfigEntry::structured(
473 "1.2.3",
474 BTreeMap::from([(
475 crate::backend::npm_package::LOCKED_NPM_NODE_VERSION_OPTION.into(),
476 crate::config::ToolConfigValue::String("1.0.0".into()),
477 )]),
478 );
479 let mut tool_origins = BTreeMap::new();
480 let mut global_tool_configs = BTreeMap::new();
481 if let Some(origin) = origin {
482 if matches!(origin, ToolConfigOrigin::GlobalConfig(_)) {
483 global_tool_configs.insert(key.clone(), entry.clone());
484 }
485 tool_origins.insert(key.clone(), origin);
486 }
487 let ctx = Ctx {
488 cas: Arc::new(Cas::new(dirs.store.clone())),
489 dirs,
490 platform: Platform::current(),
491 config: Config {
492 settings: Settings::default(),
493 sources: SourcesConfig::default(),
494 tools: BTreeMap::from([(key.clone(), "1.2.3".into())]),
495 tool_configs: BTreeMap::from([(key, entry)]),
496 global_tools: Default::default(),
497 global_tool_configs,
498 tool_origins,
499 aliases: Default::default(),
500 project_config_path: None,
501 },
502 client: reqwest::Client::new(),
503 show_progress: false,
504 };
505 let backend = NpmPackageBackend::from_id("npm:fixture-cli").unwrap();
506 (ctx, backend)
507 }
508
509 fn dynamic_fixture(
510 ctx: &Ctx,
511 backend: &NpmPackageBackend,
512 scope: ToolScope,
513 options: BTreeMap<String, String>,
514 bin_name: &str,
515 ) -> (PathBuf, DynamicToolManifest) {
516 let mut options = options;
517 options.insert(
518 crate::backend::npm_package::LOCKED_NPM_NODE_VERSION_OPTION.into(),
519 "1.0.0".into(),
520 );
521 let identity = crate::tool::InstallIdentity::new(
522 backend.id(),
523 "1.2.3",
524 ctx.platform.to_string(),
525 match scope {
526 ToolScope::Project => InstallScope::Isolated,
527 ToolScope::Global => InstallScope::Global,
528 },
529 &options,
530 vec![InstallDependency {
531 kind: InstallDependencyKind::Runtime,
532 id: "node".into(),
533 version: "1.0.0".into(),
534 identity: None,
535 }],
536 BTreeMap::new(),
537 )
538 .unwrap();
539 let root = crate::dirs::InstallLocator::new(&ctx.dirs, identity.clone())
540 .unwrap()
541 .install_root()
542 .to_path_buf();
543 let mut manifest = DynamicToolManifest::from_identity(identity.clone()).unwrap();
544 manifest.bins = vec![DynamicToolBin {
545 name: bin_name.into(),
546 path: format!("bin/{bin_name}"),
547 }];
548 (root, manifest)
549 }
550
551 fn write_dynamic_fixture(
552 ctx: &Ctx,
553 backend: &NpmPackageBackend,
554 scope: ToolScope,
555 bin_name: &str,
556 ) -> PathBuf {
557 let (root, manifest) = dynamic_fixture(ctx, backend, scope, BTreeMap::new(), bin_name);
558 let bin = root.join(format!("bin/{bin_name}"));
559 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
560 std::fs::write(&bin, b"fixture").unwrap();
561 manifest.write_atomic(&root).unwrap();
562 std::fs::write(root.join(".osdk-complete"), b"").unwrap();
563 root
564 }
565
566 #[test]
567 fn npm_manifest_lookup_is_scope_strict_and_compatibility_falls_back() {
568 let temporary = tempfile::tempdir().unwrap();
569 let project_config = temporary.path().join("project/osdk.toml");
570 let (project_ctx, backend) = npm_scope_test_ctx(
571 temporary.path(),
572 Some(ToolConfigOrigin::ProjectConfig(project_config)),
573 );
574 let global = write_dynamic_fixture(&project_ctx, &backend, ToolScope::Global, "global-bin");
575 let isolated =
576 write_dynamic_fixture(&project_ctx, &backend, ToolScope::Project, "isolated-bin");
577 let global_config = temporary.path().join("config/config.toml");
578 let (global_ctx, global_backend) = npm_scope_test_ctx(
579 temporary.path(),
580 Some(ToolConfigOrigin::GlobalConfig(global_config)),
581 );
582 let mut version = ToolVersion::new(backend.id(), "1.2.3");
583 version.options.insert(
584 crate::backend::npm_package::LOCKED_NPM_NODE_VERSION_OPTION.into(),
585 "1.0.0".into(),
586 );
587 assert_eq!(
588 global_backend
589 .global_install_root_for(&global_ctx, &version)
590 .unwrap(),
591 global
592 );
593 assert_eq!(
594 backend
595 .isolated_install_root_for(&project_ctx, &version)
596 .unwrap(),
597 isolated
598 );
599 }
600
601 #[test]
602 fn indirect_dynamic_request_preserves_structured_options() {
603 use crate::config::ToolConfigValue;
604
605 let td = tempfile::tempdir().unwrap();
606 let dirs = Dirs::resolve_from(|key| match key {
607 "OSDK_DATA_DIR" => Some(td.path().join("data").display().to_string()),
608 "OSDK_CACHE_DIR" => Some(td.path().join("cache").display().to_string()),
609 "OSDK_CONFIG_DIR" => Some(td.path().join("config").display().to_string()),
610 _ => None,
611 })
612 .unwrap();
613 let options = BTreeMap::from([(
614 "allow_builds".into(),
615 ToolConfigValue::Array(vec!["esbuild".into()]),
616 )]);
617 let ctx = Ctx {
618 cas: Arc::new(Cas::new(dirs.store.clone())),
619 dirs,
620 platform: Platform::current(),
621 config: Config {
622 settings: Settings::default(),
623 sources: SourcesConfig::default(),
624 tools: BTreeMap::from([("ni".into(), "npm:@antfu/ni@0.21.12".into())]),
625 tool_configs: BTreeMap::from([(
626 "ni".into(),
627 ToolConfigEntry::structured("npm:@antfu/ni@0.21.12", options),
628 )]),
629 global_tools: Default::default(),
630 global_tool_configs: Default::default(),
631 tool_origins: Default::default(),
632 aliases: Default::default(),
633 project_config_path: None,
634 },
635 client: reqwest::Client::new(),
636 show_progress: false,
637 };
638
639 let request = dynamic_request_from_config(&ctx, "npm:@antfu/ni").unwrap();
640 assert_eq!(request.spec.to_string(), "0.21.12");
641 assert_eq!(request.options["allow_builds"], "esbuild");
642 }
643
644 #[test]
645 fn validated_dynamic_install_requires_schema_one_matching_identity() {
646 let temporary = tempfile::tempdir().unwrap();
647 let (ctx, backend) = npm_scope_test_ctx(temporary.path(), None);
648 let mut version = ToolVersion::new(backend.id(), "1.2.3");
649 version.options.insert(
650 crate::backend::npm_package::LOCKED_NPM_NODE_VERSION_OPTION.into(),
651 "1.0.0".into(),
652 );
653 let root = backend.isolated_install_root_for(&ctx, &version).unwrap();
654 std::fs::create_dir_all(root.join("bin")).unwrap();
655 std::fs::write(root.join("bin/fixture-cli"), b"fixture").unwrap();
656 std::fs::write(root.join(".osdk-complete"), b"").unwrap();
657 let request = dynamic_request_from_config(&ctx, backend.id()).unwrap();
658
659 let report = scan_dynamic_installs(&ctx).unwrap();
660 let missing = validated_dynamic_install(&ctx, &report, &request, "1.2.3").unwrap_err();
661 assert!(
662 missing
663 .to_string()
664 .contains("missing or invalid install identity"),
665 "{missing}"
666 );
667
668 std::fs::write(
669 root.join(crate::inventory::LEGACY_INVENTORY_FILE),
670 r#"{"schema":2,"id":"npm:fixture-cli","version":"1.2.3","bins":[{"name":"fixture-cli","path":"bin/fixture-cli"}]}"#,
671 )
672 .unwrap();
673 let report = scan_dynamic_installs(&ctx).unwrap();
674 let legacy = validated_dynamic_install(&ctx, &report, &request, "1.2.3").unwrap_err();
675 assert!(
676 legacy
677 .to_string()
678 .contains("missing or invalid install identity"),
679 "{legacy}"
680 );
681 assert_eq!(report.legacy_installs.len(), 1);
682
683 let (mismatch_root, mismatched) = dynamic_fixture(
684 &ctx,
685 &backend,
686 ToolScope::Project,
687 BTreeMap::from([("installer".into(), "aube".into())]),
688 "fixture-cli",
689 );
690 std::fs::create_dir_all(mismatch_root.join("bin")).unwrap();
691 std::fs::write(mismatch_root.join("bin/fixture-cli"), b"fixture").unwrap();
692 mismatched.write_atomic(&mismatch_root).unwrap();
693 std::fs::write(mismatch_root.join(".osdk-complete"), b"").unwrap();
694 let report = scan_dynamic_installs(&ctx).unwrap();
695 let mismatch = validated_dynamic_install(&ctx, &report, &request, "1.2.3").unwrap_err();
696 assert!(
697 mismatch
698 .to_string()
699 .contains("missing or invalid install identity"),
700 "{mismatch}"
701 );
702 }
703
704 #[test]
705 fn validated_dynamic_install_requires_completion_marker() {
706 let temporary = tempfile::tempdir().unwrap();
707 let (ctx, backend) = npm_scope_test_ctx(temporary.path(), None);
708 let root = write_dynamic_fixture(&ctx, &backend, ToolScope::Project, "fixture-cli");
709 let request = dynamic_request_from_config(&ctx, backend.id()).unwrap();
710 let report = scan_dynamic_installs(&ctx).unwrap();
711
712 std::fs::remove_file(root.join(".osdk-complete")).unwrap();
713 let incomplete = validated_dynamic_install(&ctx, &report, &request, "1.2.3").unwrap_err();
714 assert!(incomplete
715 .to_string()
716 .contains("no complete selected install"));
717 assert!(incomplete.to_string().contains("reinstall"));
718
719 std::fs::create_dir(root.join(".osdk-complete")).unwrap();
720 let invalid = validated_dynamic_install(&ctx, &report, &request, "1.2.3").unwrap_err();
721 assert!(invalid.to_string().contains("no complete selected install"));
722 assert!(invalid.to_string().contains("reinstall"));
723 }
724
725 #[cfg(unix)]
726 #[test]
727 fn validated_dynamic_install_rejects_symlink_completion_marker() {
728 use std::os::unix::fs::symlink;
729
730 let temporary = tempfile::tempdir().unwrap();
731 let (ctx, backend) = npm_scope_test_ctx(temporary.path(), None);
732 let root = write_dynamic_fixture(&ctx, &backend, ToolScope::Project, "fixture-cli");
733 let request = dynamic_request_from_config(&ctx, backend.id()).unwrap();
734 let report = scan_dynamic_installs(&ctx).unwrap();
735 std::fs::remove_file(root.join(".osdk-complete")).unwrap();
736 std::fs::write(root.join("real-complete"), b"").unwrap();
737 symlink("real-complete", root.join(".osdk-complete")).unwrap();
738
739 let error = validated_dynamic_install(&ctx, &report, &request, "1.2.3").unwrap_err();
740 assert!(error.to_string().contains("no complete selected install"));
741 }
742
743 fn write_github_fixture(
744 ctx: &Ctx,
745 request_options: &BTreeMap<String, String>,
746 materials: BTreeMap<String, String>,
747 contents: &[u8],
748 ) -> PathBuf {
749 let identity = crate::tool::InstallIdentity::new(
750 "github:example/tool",
751 "1.2.3",
752 ctx.platform.to_string(),
753 InstallScope::Isolated,
754 request_options,
755 Vec::new(),
756 materials,
757 )
758 .unwrap();
759 let root = crate::dirs::InstallLocator::new(&ctx.dirs, identity.clone())
760 .unwrap()
761 .install_root()
762 .to_path_buf();
763 std::fs::create_dir_all(root.join("bin")).unwrap();
764 std::fs::write(root.join("bin/tool"), contents).unwrap();
765 let mut manifest = DynamicToolManifest::from_identity(identity.clone()).unwrap();
766 manifest.bins = vec![DynamicToolBin {
767 name: "tool".into(),
768 path: "bin/tool".into(),
769 }];
770 manifest.write_atomic(&root).unwrap();
771 let artifact_file = identity.materials["artifact-file"].clone();
772 let checksum = identity
773 .materials
774 .get("artifact-checksum")
775 .map(|value| format!(r#","checksum":{value:?}"#))
776 .unwrap_or_default();
777 std::fs::write(
778 root.join(".osdk-artifact.json"),
779 format!(
780 r#"{{"url":"https://example.test/tool.tar.gz","file_name":{artifact_file:?}{checksum},"evidence":[]}}"#
781 ),
782 )
783 .unwrap();
784 std::fs::write(root.join(".osdk-complete"), b"").unwrap();
785 root
786 }
787
788 fn write_http_fixture(ctx: &Ctx, receipt_url: &str) -> (ToolRequest, PathBuf) {
789 let backend = "http:https://downloads.example.test/tool-{version}";
790 let digest = "a".repeat(64);
791 let options = BTreeMap::from([
792 ("sha256".into(), digest.clone()),
793 ("kind".into(), "file".into()),
794 ("rename".into(), "fixture".into()),
795 ]);
796 let identity = crate::tool::InstallIdentity::new(
797 backend,
798 "1.2.3",
799 ctx.platform.to_string(),
800 InstallScope::Isolated,
801 &options,
802 Vec::new(),
803 BTreeMap::from([
804 ("artifact-file".into(), "tool-1.2.3".into()),
805 ("artifact-checksum".into(), format!("sha256:{digest}")),
806 ("artifact-url-blake3".into(), {
807 let mut hasher = blake3::Hasher::new_derive_key("osdk-http-artifact-url-v1");
808 hasher.update(receipt_url.as_bytes());
809 hasher.finalize().to_hex().to_string()
810 }),
811 ]),
812 )
813 .unwrap();
814 let root = crate::dirs::InstallLocator::new(&ctx.dirs, identity.clone())
815 .unwrap()
816 .install_root()
817 .to_path_buf();
818 std::fs::create_dir_all(root.join("bin")).unwrap();
819 std::fs::write(root.join("bin/fixture"), b"fixture").unwrap();
820 let mut manifest = DynamicToolManifest::from_identity(identity).unwrap();
821 manifest.bins = vec![DynamicToolBin {
822 name: "fixture".into(),
823 path: "bin/fixture".into(),
824 }];
825 manifest.write_atomic(&root).unwrap();
826 std::fs::write(
827 root.join(".osdk-artifact.json"),
828 serde_json::to_vec_pretty(&crate::pipeline::ArtifactReceipt {
829 url: receipt_url.into(),
830 file_name: "tool-1.2.3".into(),
831 checksum: Some(format!("sha256:{digest}")),
832 evidence: Vec::new(),
833 })
834 .unwrap(),
835 )
836 .unwrap();
837 std::fs::write(root.join(".osdk-complete"), b"").unwrap();
838 (
839 ToolRequest {
840 backend: backend.into(),
841 spec: VersionSpec::Exact("1.2.3".into()),
842 options,
843 },
844 root,
845 )
846 }
847
848 #[test]
849 fn http_restart_selection_requires_matching_receipt_url_fingerprint() {
850 let temporary = tempfile::tempdir().unwrap();
851 let (ctx, _) = npm_scope_test_ctx(temporary.path(), None);
852 let receipt_url = "https://downloads.example.test/tool-1.2.3";
853 let (request, root) = write_http_fixture(&ctx, receipt_url);
854 let report = scan_dynamic_installs(&ctx).unwrap();
855 let selected = validated_dynamic_install(&ctx, &report, &request, "1.2.3").unwrap();
856 assert_eq!(selected.install_root(), root);
857 assert_eq!(selected.bin_names(), vec!["fixture"]);
858
859 let mut receipt = crate::pipeline::artifact_receipt_at(&root).unwrap();
860 receipt.url = "https://downloads.example.test/substitute-1.2.3".into();
861 std::fs::write(
862 root.join(".osdk-artifact.json"),
863 serde_json::to_vec_pretty(&receipt).unwrap(),
864 )
865 .unwrap();
866 let report = scan_dynamic_installs(&ctx).unwrap();
867 let error = validated_dynamic_install(&ctx, &report, &request, "1.2.3").unwrap_err();
868 assert!(error.to_string().contains("receipt"), "{error}");
869 }
870
871 #[test]
872 fn unlocked_github_request_recovers_one_complete_identity() {
873 let temporary = tempfile::tempdir().unwrap();
874 let (mut ctx, _) = npm_scope_test_ctx(temporary.path(), None);
875 ctx.config.tools = BTreeMap::from([("github:example/tool".into(), "1.2.3".into())]);
876 ctx.config.tool_configs.clear();
877 let materials = BTreeMap::from([
878 ("artifact-file".into(), "tool.tar.gz".into()),
879 (
880 "artifact-checksum".into(),
881 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
882 ),
883 ]);
884 let root = write_github_fixture(&ctx, &BTreeMap::new(), materials, b"one");
885 let request = dynamic_request_from_config(&ctx, "github:example/tool").unwrap();
886 let report = scan_dynamic_installs(&ctx).unwrap();
887
888 let selected = validated_dynamic_install(&ctx, &report, &request, "1.2.3").unwrap();
889 assert_eq!(selected.install_root(), root);
890 assert_eq!(
891 std::fs::read(selected.executable("tool").unwrap()).unwrap(),
892 b"one"
893 );
894 }
895
896 #[test]
897 fn validated_dynamic_install_rejects_manifest_replacement_before_using_cached_bins() {
898 let temporary = tempfile::tempdir().unwrap();
899 let (mut ctx, _) = npm_scope_test_ctx(temporary.path(), None);
900 ctx.config.tools = BTreeMap::from([("github:example/tool".into(), "1.2.3".into())]);
901 ctx.config.tool_configs.clear();
902 let materials = BTreeMap::from([
903 ("artifact-file".into(), "tool.tar.gz".into()),
904 (
905 "artifact-checksum".into(),
906 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
907 ),
908 ]);
909 let root = write_github_fixture(&ctx, &BTreeMap::new(), materials, b"one");
910 let request = dynamic_request_from_config(&ctx, "github:example/tool").unwrap();
911 let report = scan_dynamic_installs(&ctx).unwrap();
912
913 let mut replacement = DynamicToolManifest::load(&root).unwrap();
914 replacement.bins.clear();
915 replacement.write_atomic(&root).unwrap();
916 assert_eq!(report.installs[0].manifest.bins[0].name, "tool");
917 assert!(DynamicToolManifest::load(&root).unwrap().bins.is_empty());
918
919 let error = validated_dynamic_install(&ctx, &report, &request, "1.2.3").unwrap_err();
920 assert!(
921 error.to_string().contains("changed after inventory scan"),
922 "{error}"
923 );
924 }
925
926 #[test]
927 fn unlocked_github_request_rejects_ambiguous_complete_identities() {
928 let temporary = tempfile::tempdir().unwrap();
929 let (mut ctx, _) = npm_scope_test_ctx(temporary.path(), None);
930 ctx.config.tools = BTreeMap::from([("github:example/tool".into(), "1.2.3".into())]);
931 ctx.config.tool_configs.clear();
932 for (file, digest) in [
933 (
934 "tool-a.tar.gz",
935 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
936 ),
937 (
938 "tool-b.tar.gz",
939 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
940 ),
941 ] {
942 write_github_fixture(
943 &ctx,
944 &BTreeMap::new(),
945 BTreeMap::from([
946 ("artifact-file".into(), file.into()),
947 ("artifact-checksum".into(), digest.into()),
948 ]),
949 file.as_bytes(),
950 );
951 }
952 let request = dynamic_request_from_config(&ctx, "github:example/tool").unwrap();
953 let report = scan_dynamic_installs(&ctx).unwrap();
954
955 let error = validated_dynamic_install(&ctx, &report, &request, "1.2.3").unwrap_err();
956 assert!(error.to_string().contains("multiple complete installs"));
957 assert!(error.to_string().contains("lockfile"));
958 }
959
960 #[test]
961 fn configured_dynamic_ids_include_indirect_requests_without_inventory() {
962 let temporary = tempfile::tempdir().unwrap();
963 let (mut ctx, _) = npm_scope_test_ctx(temporary.path(), None);
964 ctx.config.tools = BTreeMap::from([("tool.ni".into(), "npm:@antfu/ni@1.2.3".into())]);
965 ctx.config.tool_configs.clear();
966
967 assert_eq!(
968 configured_dynamic_ids(&ctx, &ScanReport::default()),
969 vec!["npm:@antfu/ni"]
970 );
971 }
972
973 #[test]
974 fn configured_dynamic_ids_and_requests_support_http_templates() {
975 let temporary = tempfile::tempdir().unwrap();
976 let (mut ctx, _) = npm_scope_test_ctx(temporary.path(), None);
977 let backend = "http:https://downloads.example.test/tool-{version}";
978 let digest = "a".repeat(64);
979 ctx.config.tools = BTreeMap::from([(backend.into(), "1.2.3".into())]);
980 ctx.config.tool_configs = BTreeMap::from([(
981 backend.into(),
982 ToolConfigEntry::structured(
983 "1.2.3",
984 BTreeMap::from([
985 (
986 "sha256".into(),
987 crate::config::ToolConfigValue::String(digest),
988 ),
989 (
990 "kind".into(),
991 crate::config::ToolConfigValue::String("file".into()),
992 ),
993 (
994 "rename".into(),
995 crate::config::ToolConfigValue::String("fixture".into()),
996 ),
997 ]),
998 ),
999 )]);
1000
1001 assert_eq!(
1002 configured_dynamic_ids(&ctx, &ScanReport::default()),
1003 vec![backend]
1004 );
1005 let request = dynamic_request_from_config(&ctx, backend).unwrap();
1006 assert_eq!(request.backend, backend);
1007 assert_eq!(request.spec, VersionSpec::Exact("1.2.3".into()));
1008 assert_eq!(request.options["rename"], "fixture");
1009 }
1010
1011 #[cfg(unix)]
1012 #[test]
1013 fn unix_shim_is_symlink() {
1014 use super::*;
1015
1016 let td = tempfile::tempdir().unwrap();
1017 let shims = td.path().join("shims");
1018 let fake_bin = td.path().join("osdk-shim");
1019 std::fs::write(&fake_bin, b"#!/bin/sh\n").unwrap();
1020 generate_shim_in(&shims, "node", &fake_bin).unwrap();
1021 let link = shims.join("node");
1022 assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
1023 assert_eq!(std::fs::read_link(&link).unwrap(), fake_bin);
1024 }
1025
1026 #[cfg(unix)]
1027 #[test]
1028 fn managed_shim_cleanup_removes_generated_links_but_preserves_regular_files() {
1029 use super::*;
1030
1031 let td = tempfile::tempdir().unwrap();
1032 let dirs = Dirs::resolve_from(|key| match key {
1033 "OSDK_DATA_DIR" => Some(td.path().join("data").display().to_string()),
1034 _ => None,
1035 })
1036 .unwrap();
1037 let fake_bin = td.path().join("bin/osdk-shim");
1038 std::fs::create_dir_all(fake_bin.parent().unwrap()).unwrap();
1039 std::fs::write(&fake_bin, b"#!/bin/sh\n").unwrap();
1040
1041 generate_shim(&dirs, "npm", &fake_bin).unwrap();
1042 assert!(remove_managed_shim(&dirs, "npm").unwrap());
1043 assert!(!dirs.shims().join("npm").exists());
1044
1045 std::fs::write(dirs.shims().join("npx"), b"user-owned").unwrap();
1046 assert!(!remove_managed_shim(&dirs, "npx").unwrap());
1047 assert_eq!(
1048 std::fs::read(dirs.shims().join("npx")).unwrap(),
1049 b"user-owned"
1050 );
1051 }
1052
1053 #[cfg(unix)]
1054 #[test]
1055 fn node_routing_names_include_bundled_npm_without_changing_backend_ownership() {
1056 use std::sync::Arc;
1057
1058 use super::*;
1059 use crate::backend::node::NodeBackend;
1060 use crate::config::{Config, Settings, SourcesConfig};
1061 use crate::platform::Platform;
1062 use crate::store::Cas;
1063
1064 let td = tempfile::tempdir().unwrap();
1065 let dirs = Dirs::resolve_from(|key| match key {
1066 "OSDK_DATA_DIR" => Some(td.path().join("data").display().to_string()),
1067 "OSDK_CACHE_DIR" => Some(td.path().join("cache").display().to_string()),
1068 "OSDK_CONFIG_DIR" => Some(td.path().join("config").display().to_string()),
1069 "OSDK_STORE_DIR" => Some(td.path().join("store").display().to_string()),
1070 "OSDK_INSTALL_DIR" => Some(td.path().join("installs").display().to_string()),
1071 _ => None,
1072 })
1073 .unwrap();
1074 dirs.ensure().unwrap();
1075 let ctx = Ctx {
1076 cas: Arc::new(Cas::new(dirs.store.clone())),
1077 dirs,
1078 platform: Platform::current(),
1079 config: Config {
1080 settings: Settings::default(),
1081 sources: SourcesConfig::default(),
1082 tools: Default::default(),
1083 tool_configs: Default::default(),
1084 global_tools: Default::default(),
1085 global_tool_configs: Default::default(),
1086 tool_origins: Default::default(),
1087 aliases: Default::default(),
1088 project_config_path: None,
1089 },
1090 client: reqwest::Client::new(),
1091 show_progress: false,
1092 };
1093 let version = ToolVersion::new("node", "20.0.0");
1094 let bin = NodeBackend.bin_paths(&ctx, &version).unwrap().remove(0);
1095 std::fs::create_dir_all(&bin).unwrap();
1096 for name in ["node", "npm", "npx"] {
1097 let path = bin.join(name);
1098 std::fs::write(&path, b"#!/bin/sh\n").unwrap();
1099 use std::os::unix::fs::PermissionsExt;
1100 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
1101 }
1102
1103 let owned = NodeBackend.bin_names(&ctx, &version).unwrap();
1104 assert!(owned.contains(&"node".to_string()));
1105 assert!(!owned.contains(&"npm".to_string()));
1106 assert!(!owned.contains(&"npx".to_string()));
1107
1108 let routed = routed_bin_names(&ctx, &NodeBackend, &version).unwrap();
1109 assert!(routed.contains(&"npm".to_string()));
1110 assert!(routed.contains(&"npx".to_string()));
1111 }
1112}