Skip to main content

osdk_core/shim/
mod.rs

1//! Shim generation.
2//!
3//! A shim is a stand-in for a tool's executable, placed in the shims dir (which
4//! the user puts on PATH). Invoking it dispatches to the active version via the
5//! `osdk-shim` launcher.
6//!
7//! - Unix: a symlink from `shims/<name>` to the `osdk-shim` binary. The launcher
8//!   inspects argv[0] to learn which tool to run.
9//! - Windows: no symlink (privilege). We emit `shims/<name>.cmd` and an
10//!   extension-less bash wrapper `shims/<name>` so cmd.exe/PowerShell and
11//!   Git-Bash both work, each invoking `osdk-shim.exe`.
12
13use 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
24/// Executable names that should route through the shim for an installed
25/// backend version. These are deliberately separate from backend ownership:
26/// Node does not own npm/npx, but its bundled launchers still need routing
27/// shims so Node-only activations cannot bypass package-registry preflight.
28pub 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
47/// Scan all persisted dynamic-tool manifests under the installs tree.
48pub fn scan_dynamic_installs(ctx: &Ctx) -> Result<ScanReport> {
49    inventory::scan_installs(&ctx.dirs.installs, &ScanOptions::default())
50}
51
52/// Dynamic backend ids referenced by the current config. Persisted install
53/// manifests intentionally never own aliases or configuration keys.
54pub 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
68/// Dynamic backend ids relevant to lifecycle operations that survive restarts:
69/// configured ids plus anything found on disk through inventory scanning.
70pub 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
77/// Resolve a dynamic backend request from the merged config, supporting both a
78/// direct dynamic backend key (`"npm:@scope/pkg" = "1.2.3"`) and an indirection
79/// key whose value is the dynamic request (`tool.ni = "npm:@scope/pkg@1.2.3"`).
80pub 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/// One persisted dynamic install whose complete identity has been checked
110/// against the active request. Callers keep this value through path and
111/// executable selection so security-sensitive inventory data is not reloaded.
112#[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
146/// Load the selected dynamic install and require it to prove the same backend,
147/// exact version, and public option identity as the configured request. Legacy
148/// `.osdk-tool.json` and missing inventories are discoverable elsewhere, but
149/// cannot authorize PATH exposure or execution.
150pub 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
218/// Deterministic manifest-backed bin ownership used after a process restart,
219/// even when the dynamic backend implementation itself does not expose
220/// `bin_names` yet.
221pub 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
327/// Generate a shim named `name` in the shims dir pointing at `osdk_shim_bin`.
328pub 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    // .cmd wrapper for cmd.exe / PowerShell
347    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    // extension-less bash wrapper for Git-Bash / MSYS
352    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
361/// Remove a shim by name (all its platform variants).
362pub 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
372/// Remove a shim only when it has osdk's generated shape. This lets `reshim`
373/// reconcile obsolete routing aliases without deleting unrelated files that a
374/// user may have placed in the shims directory.
375pub 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
424/// Locate the installed `osdk-shim` binary. It is expected to sit next to the
425/// `osdk` binary (same dir). Falls back to the shims dir.
426pub 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}