1use std::collections::BTreeMap;
14use std::path::PathBuf;
15
16use crate::backend::registry::Registry;
17use crate::backend::Ctx;
18use crate::version::resolver::resolve_active;
19use crate::version::{select_version, ToolVersion, VersionInfo, VersionSpec};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Shell {
23 Bash,
24 Zsh,
25 Fish,
26 Powershell,
27}
28
29impl std::str::FromStr for Shell {
30 type Err = crate::error::Error;
31 fn from_str(s: &str) -> crate::Result<Self> {
32 Ok(match s.to_ascii_lowercase().as_str() {
33 "bash" => Shell::Bash,
34 "zsh" => Shell::Zsh,
35 "fish" => Shell::Fish,
36 "powershell" | "pwsh" => Shell::Powershell,
37 other => {
38 return Err(crate::error::Error::other(format!(
39 "unsupported shell `{other}` (expected bash|zsh|fish|powershell)"
40 )))
41 }
42 })
43 }
44}
45
46pub fn activation_script(shell: Shell, osdk_bin: &str) -> String {
49 match shell {
50 Shell::Bash => format!(
51 r#"# osdk shell integration (bash)
52_osdk_hook() {{
53 local out
54 out="$({bin} hook-env --shell bash 2>/dev/null)" && eval "$out"
55}}
56if [[ ";${{PROMPT_COMMAND:-}};" != *";_osdk_hook;"* ]]; then
57 PROMPT_COMMAND="_osdk_hook${{PROMPT_COMMAND:+;$PROMPT_COMMAND}}"
58fi
59_osdk_hook
60"#,
61 bin = shell_quote(Shell::Bash, osdk_bin)
62 ),
63 Shell::Zsh => format!(
64 r#"# osdk shell integration (zsh)
65_osdk_hook() {{
66 local out
67 out="$({bin} hook-env --shell zsh 2>/dev/null)" && eval "$out"
68}}
69typeset -ag precmd_functions
70if [[ -z ${{precmd_functions[(r)_osdk_hook]}} ]]; then
71 precmd_functions+=(_osdk_hook)
72fi
73_osdk_hook
74"#,
75 bin = shell_quote(Shell::Zsh, osdk_bin)
76 ),
77 Shell::Fish => format!(
78 r#"# osdk shell integration (fish)
79function _osdk_hook --on-variable PWD --on-event fish_prompt
80 {bin} hook-env --shell fish 2>/dev/null | source
81end
82_osdk_hook
83"#,
84 bin = shell_quote(Shell::Fish, osdk_bin)
85 ),
86 Shell::Powershell => format!(
87 r#"# osdk shell integration (powershell)
88$script:OsdkHookRunning = $false
89function Invoke-OsdkHook {{
90 $out = & {bin} hook-env --shell powershell 2>$null
91 if ($out) {{ Invoke-Expression ($out -join "`n") }}
92}}
93Invoke-OsdkHook
94$ExecutionContext.SessionState.InvokeCommand.PostCommandLookupAction = {{
95 if ($script:OsdkHookRunning) {{ return }}
96 $script:OsdkHookRunning = $true
97 try {{ Invoke-OsdkHook }} finally {{ $script:OsdkHookRunning = $false }}
98}}
99"#,
100 bin = powershell_quote(osdk_bin)
101 ),
102 }
103}
104
105pub fn deactivation_script(shell: Shell) -> String {
108 match shell {
109 Shell::Bash => r#"# osdk shell deactivation (bash)
110if [ -n "${OSDK_MANAGED_ENV:-}" ]; then
111 _osdk_saved_ifs=$IFS
112 IFS=,
113 for _osdk_key in $OSDK_MANAGED_ENV; do
114 case "$_osdk_key" in
115 ''|*[!A-Za-z0-9_]*) continue ;;
116 esac
117 eval "_osdk_present=\${OSDK_ORIG_${_osdk_key}_PRESENT:-0}"
118 if [ "$_osdk_present" = 1 ]; then
119 eval "export ${_osdk_key}=\${OSDK_ORIG_${_osdk_key}}"
120 else
121 unset "$_osdk_key"
122 fi
123 unset "OSDK_ORIG_${_osdk_key}" "OSDK_ORIG_${_osdk_key}_PRESENT" "OSDK_ORIG_${_osdk_key}_SET"
124 done
125 IFS=$_osdk_saved_ifs
126 unset _osdk_saved_ifs _osdk_key _osdk_present
127fi
128if [ -n "${OSDK_ORIGINAL_PATH_SET+x}" ]; then export PATH="$OSDK_ORIGINAL_PATH"; fi
129PROMPT_COMMAND=";${PROMPT_COMMAND:-};"
130PROMPT_COMMAND="${PROMPT_COMMAND//;_osdk_hook;/;}"
131PROMPT_COMMAND="${PROMPT_COMMAND#;}"
132PROMPT_COMMAND="${PROMPT_COMMAND%;}"
133unset -f _osdk_hook 2>/dev/null || true
134unset OSDK_MANAGED_ENV OSDK_ORIGINAL_PATH OSDK_ORIGINAL_PATH_SET
135"#
136 .to_string(),
137 Shell::Zsh => r#"# osdk shell deactivation (zsh)
138if [[ -n ${OSDK_MANAGED_ENV:-} ]]; then
139 local _osdk_key
140 for _osdk_key in ${(s:,:)OSDK_MANAGED_ENV}; do
141 [[ $_osdk_key == [A-Za-z_][A-Za-z0-9_]# ]] || continue
142 local _osdk_present_var="OSDK_ORIG_${_osdk_key}_PRESENT"
143 local _osdk_original_var="OSDK_ORIG_${_osdk_key}"
144 if [[ ${(P)_osdk_present_var:-0} == 1 ]]; then
145 export "$_osdk_key=${(P)_osdk_original_var}"
146 else
147 unset "$_osdk_key"
148 fi
149 unset "OSDK_ORIG_${_osdk_key}" "OSDK_ORIG_${_osdk_key}_PRESENT" "OSDK_ORIG_${_osdk_key}_SET"
150 done
151fi
152if [[ -n ${OSDK_ORIGINAL_PATH_SET+x} ]]; then export PATH="$OSDK_ORIGINAL_PATH"; fi
153precmd_functions=(${precmd_functions:#_osdk_hook})
154unfunction _osdk_hook 2>/dev/null || true
155unset OSDK_MANAGED_ENV OSDK_ORIGINAL_PATH OSDK_ORIGINAL_PATH_SET
156"#
157 .to_string(),
158 Shell::Fish => r#"# osdk shell deactivation (fish)
159if set -q OSDK_MANAGED_ENV
160 for _osdk_key in (string split , -- $OSDK_MANAGED_ENV)
161 string match -rq '^[A-Za-z_][A-Za-z0-9_]*$' -- $_osdk_key; or continue
162 set _osdk_original OSDK_ORIG_$_osdk_key
163 set _osdk_present $_osdk_original"_PRESENT"
164 if test "$$_osdk_present" = 1
165 set -gx $_osdk_key "$$_osdk_original"
166 else
167 set -e $_osdk_key
168 end
169 set -e $_osdk_original $_osdk_present $_osdk_original"_SET"
170 end
171 set -e _osdk_key _osdk_original _osdk_present
172end
173if set -q OSDK_ORIGINAL_PATH_SET
174 set -gx PATH $OSDK_ORIGINAL_PATH
175end
176functions -e _osdk_hook
177set -e OSDK_MANAGED_ENV OSDK_ORIGINAL_PATH OSDK_ORIGINAL_PATH_SET
178"#
179 .to_string(),
180 Shell::Powershell => r#"# osdk shell deactivation (powershell)
181if (Test-Path Env:OSDK_MANAGED_ENV) {
182 foreach ($osdkKey in ($env:OSDK_MANAGED_ENV -split ',')) {
183 if ($osdkKey -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { continue }
184 $original = "OSDK_ORIG_${osdkKey}"
185 $present = "${original}_PRESENT"
186 if ([Environment]::GetEnvironmentVariable($present) -eq '1') {
187 [Environment]::SetEnvironmentVariable($osdkKey, [Environment]::GetEnvironmentVariable($original))
188 } else {
189 Remove-Item "Env:$osdkKey" -ErrorAction SilentlyContinue
190 }
191 Remove-Item "Env:$original","Env:$present","Env:${original}_SET" -ErrorAction SilentlyContinue
192 }
193}
194if (Test-Path Env:OSDK_ORIGINAL_PATH_SET) { $env:PATH = $env:OSDK_ORIGINAL_PATH }
195$ExecutionContext.SessionState.InvokeCommand.PostCommandLookupAction = $null
196Remove-Item Function:Invoke-OsdkHook -ErrorAction SilentlyContinue
197Remove-Variable OsdkHookRunning -Scope Script -ErrorAction SilentlyContinue
198Remove-Item Env:OSDK_MANAGED_ENV,Env:OSDK_ORIGINAL_PATH,Env:OSDK_ORIGINAL_PATH_SET -ErrorAction SilentlyContinue
199"#
200 .to_string(),
201 }
202}
203
204pub struct EnvDelta {
206 pub path_prepend: Vec<PathBuf>,
209 pub set_vars: BTreeMap<String, String>,
211 pub unset_vars: Vec<String>,
213}
214
215pub fn compute_env_delta(
220 ctx: &Ctx,
221 registry: &Registry,
222 cwd: &std::path::Path,
223) -> crate::Result<EnvDelta> {
224 let mut path_prepend = Vec::new();
225 let mut set_vars = BTreeMap::new();
226 let mut has_generated_shim = false;
227 let dynamic_report = crate::shim::scan_dynamic_installs(ctx)?;
228 let project_npm_bin = trusted_project_npm_bin(ctx, cwd)?;
229 let mut backend_ids = registry
230 .all()
231 .iter()
232 .map(|backend| backend.id().to_string())
233 .collect::<Vec<_>>();
234 backend_ids.extend(crate::shim::configured_and_installed_dynamic_ids(
235 ctx,
236 &dynamic_report,
237 ));
238 backend_ids.sort();
239 backend_ids.dedup();
240
241 for backend_id in backend_ids {
242 let Ok(backend) = registry.get(&backend_id) else {
243 continue;
244 };
245 let dynamic_request = crate::shim::dynamic_request_from_config(ctx, backend.id());
246 if dynamic_request.as_ref().is_some_and(|request| {
250 request.backend.starts_with("npm:")
251 && project_npm_bin.is_some()
252 && crate::shim::configured_npm_scope(ctx, request)
253 .is_ok_and(|scope| scope == Some(crate::npm_tools::ToolScope::Project))
254 }) {
255 continue;
256 }
257 let active = match resolve_active(
258 backend.id(),
259 cwd,
260 &ctx.config.tools,
261 backend.idiomatic_files(),
262 ) {
263 Some(a) => Some((a.spec, a.is_range)),
264 None => dynamic_request
265 .as_ref()
266 .map(|request| (request.spec.to_string(), false)),
267 };
268 let Some((active_spec, active_is_range)) = active else {
269 continue;
270 };
271 let expanded = ctx
273 .config
274 .expand_alias(backend.id(), &active_spec)
275 .unwrap_or(active_spec);
276 let spec = strip_distribution_prefix(&expanded);
277 let parsed = if active_is_range {
278 VersionSpec::parse_range(spec).unwrap_or_else(|_| VersionSpec::parse(spec))
279 } else {
280 VersionSpec::parse(spec)
281 };
282 let version = match (&dynamic_request, &parsed) {
286 (Some(_), VersionSpec::Exact(version)) => Some(version.clone()),
287 _ => {
288 let installed = if dynamic_request.is_some() {
289 backend.list_installed(ctx)?
290 } else {
291 backend.list_installed(ctx).unwrap_or_default()
292 };
293 if backend.id() == "python" {
294 crate::backend::python::select_installed(spec, &installed)
295 } else {
296 match &parsed {
297 VersionSpec::Exact(v) if installed.iter().any(|i| i == v) => {
298 Some(v.clone())
299 }
300 _ => {
301 let infos: Vec<VersionInfo> =
302 installed.iter().map(VersionInfo::stable).collect();
303 select_version(&parsed, &infos).map(|vi| vi.version.clone())
304 }
305 }
306 }
307 }
308 };
309 let version = match version {
310 Some(v) => v,
311 None => continue,
312 };
313 let mut tv = ToolVersion::new(backend.id(), &version);
314 let dynamic_install = if let Some(request) = dynamic_request.as_ref() {
315 tv.options = request.options.clone();
316 Some(crate::shim::validated_dynamic_install(
317 ctx,
318 &dynamic_report,
319 request,
320 &version,
321 )?)
322 } else {
323 None
324 };
325 let routed_names = if let Some(install) = dynamic_install.as_ref() {
326 install.bin_names()
327 } else {
328 crate::shim::routed_bin_names(ctx, backend.as_ref(), &tv).unwrap_or_default()
329 };
330 if routed_names
331 .into_iter()
332 .any(|name| shim_exists(&ctx.dirs.shims(), &name))
333 {
334 has_generated_shim = true;
335 }
336 let bins = if let Some(install) = dynamic_install.as_ref() {
337 install.bin_paths()
338 } else {
339 backend.bin_paths(ctx, &tv).unwrap_or_default()
340 };
341 for b in bins {
342 if b.exists() {
343 path_prepend.push(b);
344 }
345 }
346 let env = if dynamic_request.is_some() {
347 Some(backend.exec_env(ctx, &tv)?)
348 } else {
349 backend.exec_env(ctx, &tv).ok()
350 };
351 if let Some(env) = env {
352 for (k, v) in env {
353 set_vars.insert(k, v);
354 }
355 }
356 }
357
358 prioritize_managed_paths(&mut path_prepend, &ctx.dirs.shims(), has_generated_shim);
359 if let Some(project_bin) = project_npm_bin {
360 prioritize_project_bin(&mut path_prepend, project_bin);
361 }
362
363 let previous = std::env::var("OSDK_MANAGED_ENV").unwrap_or_default();
364 let unset_vars = previous
365 .split(',')
366 .filter(|key| !key.is_empty() && valid_env_name(key) && !set_vars.contains_key(*key))
367 .map(str::to_string)
368 .collect();
369
370 Ok(EnvDelta {
371 path_prepend,
372 set_vars,
373 unset_vars,
374 })
375}
376
377fn trusted_project_npm_bin(ctx: &Ctx, cwd: &std::path::Path) -> crate::Result<Option<PathBuf>> {
378 let config_path = ctx.config.tool_origins.iter().find_map(|(key, origin)| {
379 let configured = key.starts_with("npm:")
380 || ctx
381 .config
382 .tools
383 .get(key)
384 .is_some_and(|value| value.starts_with("npm:"));
385 match (configured, origin) {
386 (true, crate::config::ToolConfigOrigin::ProjectConfig(path)) => Some(path),
387 _ => None,
388 }
389 });
390 let Some(config_path) = config_path else {
391 return Ok(None);
392 };
393 let Some(config_root) = config_path.parent() else {
394 return Ok(None);
395 };
396 let Ok(config_root) = dunce::canonicalize(config_root) else {
397 return Ok(None);
398 };
399 let Ok(cwd) = dunce::canonicalize(cwd) else {
400 return Ok(None);
401 };
402 let Some(project_root) = nearest_package_root(&cwd) else {
403 return Ok(None);
404 };
405 if !same_existing_path(&config_root, &project_root)
406 || !crate::trust::is_trusted(
407 &ctx.dirs.config,
408 config_path,
409 std::env::var_os("OSDK_TRUSTED_CONFIG_PATHS").as_ref(),
410 )
411 .unwrap_or(false)
412 {
413 return Ok(None);
414 }
415 let configured_specs = project_npm_configured_specs(ctx, config_path)?;
416 crate::backend::npm_package::validated_project_bin_dir(&project_root, &configured_specs)
417 .map_err(|error| crate::error::Error::other(error.to_string()))
418}
419
420fn project_npm_configured_specs(
421 ctx: &Ctx,
422 config_path: &std::path::Path,
423) -> crate::Result<BTreeMap<String, String>> {
424 let config_root = config_path.parent().ok_or_else(|| {
425 crate::error::Error::other(format!(
426 "project config has no parent: {}",
427 config_path.display()
428 ))
429 })?;
430 let config = crate::config::Config::load(&ctx.dirs.user_config_file(), config_root)?;
431 canonical_project_npm_specs(
432 config
433 .tools
434 .iter()
435 .filter(|(key, _)| {
436 matches!(
437 config.tool_origins.get(*key),
438 Some(crate::config::ToolConfigOrigin::ProjectConfig(path))
439 if same_existing_path(path, config_path)
440 )
441 })
442 .map(|(key, value)| (key.clone(), value.clone())),
443 )
444}
445
446pub fn canonical_project_npm_specs(
449 entries: impl IntoIterator<Item = (String, String)>,
450) -> crate::Result<BTreeMap<String, String>> {
451 let mut entries = entries.into_iter().collect::<Vec<_>>();
452 entries.sort_by(|left, right| left.0.cmp(&right.0));
453 let mut configured = BTreeMap::<String, (String, String)>::new();
454 for (key, value) in entries {
455 let entry = if key.starts_with("npm:") {
456 Some((key.clone(), value))
457 } else {
458 crate::version::ToolRequest::parse(&value)
459 .ok()
460 .filter(|request| request.backend.starts_with("npm:"))
461 .map(|request| (request.backend, request.spec.to_string()))
462 };
463 let Some((backend, spec)) = entry else {
464 continue;
465 };
466 if let Some((existing_key, existing_spec)) = configured.get(&backend) {
467 if existing_spec != &spec {
468 return Err(crate::error::Error::other(format!(
469 "conflicting project npm aliases for `{backend}`: `{existing_key}` configures `{existing_spec}`, but `{key}` configures `{spec}`"
470 )));
471 }
472 continue;
473 }
474 configured.insert(backend, (key, spec));
475 }
476 Ok(configured
477 .into_iter()
478 .map(|(backend, (_, spec))| (backend, spec))
479 .collect())
480}
481
482fn nearest_package_root(cwd: &std::path::Path) -> Option<PathBuf> {
483 let root = cwd.ancestors().find_map(|directory| {
484 let package_json = directory.join("package.json");
485 match std::fs::symlink_metadata(&package_json) {
486 Ok(metadata) => Some(
487 metadata
488 .file_type()
489 .is_file()
490 .then(|| directory.to_path_buf()),
491 ),
492 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
493 Err(_) => Some(None),
495 }
496 })??;
497 let canonical_root = dunce::canonicalize(&root).ok()?;
498 let canonical_package = dunce::canonicalize(root.join("package.json")).ok()?;
499 (canonical_package.is_file()
500 && canonical_package
501 .parent()
502 .is_some_and(|parent| same_existing_path(parent, &canonical_root)))
503 .then_some(canonical_root)
504}
505
506fn prioritize_project_bin(paths: &mut Vec<PathBuf>, project_bin: PathBuf) {
507 let node_collision = directory_provides_command(&project_bin, "node").unwrap_or(true);
508 if node_collision {
512 return;
513 }
514 paths.retain(|path| !same_existing_path(path, &project_bin));
515 paths.insert(0, project_bin);
516}
517
518fn same_existing_path(left: &std::path::Path, right: &std::path::Path) -> bool {
519 left == right || same_file::is_same_file(left, right).unwrap_or(false)
520}
521
522fn directory_provides_command(directory: &std::path::Path, command: &str) -> std::io::Result<bool> {
523 for entry in std::fs::read_dir(directory)? {
524 let file_name = entry?.file_name();
525 if command_name_matches(&file_name, command) {
526 return Ok(true);
527 }
528 }
529 Ok(false)
530}
531
532fn command_name_matches(file_name: &std::ffi::OsStr, command: &str) -> bool {
533 #[cfg(windows)]
534 {
535 let file_name = file_name.to_string_lossy();
536 file_name.eq_ignore_ascii_case(command)
537 || file_name
538 .get(..command.len() + 1)
539 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(&format!("{command}.")))
540 }
541 #[cfg(not(windows))]
542 {
543 file_name == std::ffi::OsStr::new(command)
544 }
545}
546
547fn prioritize_managed_paths(
548 paths: &mut Vec<PathBuf>,
549 shims: &std::path::Path,
550 has_generated_shim: bool,
551) {
552 paths.retain(|path| path != shims);
560 paths.sort_by_key(|path| managed_runtime_path_priority(path));
561 if has_generated_shim && !paths.is_empty() {
562 paths.insert(0, shims.to_path_buf());
563 }
564}
565
566fn shim_exists(shims: &std::path::Path, name: &str) -> bool {
567 shims.join(name).is_file() || (cfg!(windows) && shims.join(format!("{name}.cmd")).is_file())
568}
569
570fn managed_runtime_path_priority(path: &std::path::Path) -> u8 {
571 let components: std::collections::BTreeSet<_> = path
572 .components()
573 .filter_map(|component| component.as_os_str().to_str())
574 .collect();
575 if ["npm", "pnpm", "yarn"]
576 .iter()
577 .any(|backend| components.contains(backend))
578 {
579 0
580 } else if components.contains("node") {
581 1
582 } else {
583 2
584 }
585}
586
587pub fn render_hook_env(shell: Shell, delta: &EnvDelta) -> String {
589 let mut out = String::new();
590 render_path_reset(shell, &delta.path_prepend, &mut out);
591
592 for key in &delta.unset_vars {
593 if valid_env_name(key) {
594 render_restore_var(shell, key, &mut out);
595 }
596 }
597
598 for (k, v) in &delta.set_vars {
599 if valid_env_name(k) {
600 render_capture_var(shell, k, &mut out);
601 match shell {
602 Shell::Fish => out.push_str(&format!("set -gx {} {}\n", k, shell_quote(shell, v))),
603 Shell::Powershell => {
604 out.push_str(&format!("$env:{} = {}\n", k, powershell_quote(v)))
605 }
606 _ => out.push_str(&format!("export {}={}\n", k, shell_quote(shell, v))),
607 }
608 }
609 }
610 let managed = delta
611 .set_vars
612 .keys()
613 .filter(|key| valid_env_name(key))
614 .cloned()
615 .collect::<Vec<_>>()
616 .join(",");
617 match shell {
618 Shell::Fish if managed.is_empty() => out.push_str("set -e OSDK_MANAGED_ENV\n"),
619 Shell::Fish => out.push_str(&format!(
620 "set -gx OSDK_MANAGED_ENV {}\n",
621 shell_quote(shell, &managed)
622 )),
623 Shell::Powershell if managed.is_empty() => {
624 out.push_str("Remove-Item Env:OSDK_MANAGED_ENV -ErrorAction SilentlyContinue\n")
625 }
626 Shell::Powershell => out.push_str(&format!(
627 "$env:OSDK_MANAGED_ENV = {}\n",
628 powershell_quote(&managed)
629 )),
630 _ if managed.is_empty() => out.push_str("unset OSDK_MANAGED_ENV\n"),
631 _ => out.push_str(&format!(
632 "export OSDK_MANAGED_ENV={}\n",
633 shell_quote(shell, &managed)
634 )),
635 }
636 out
637}
638
639fn render_path_reset(shell: Shell, paths: &[PathBuf], out: &mut String) {
640 match shell {
641 Shell::Fish => {
642 out.push_str(
643 "if not set -q OSDK_ORIGINAL_PATH_SET\n set -gx OSDK_ORIGINAL_PATH $PATH\n set -gx OSDK_ORIGINAL_PATH_SET 1\nend\n",
644 );
645 if paths.is_empty() {
646 out.push_str("set -gx PATH $OSDK_ORIGINAL_PATH\n");
647 } else {
648 out.push_str(&format!(
649 "set -gx PATH {} $OSDK_ORIGINAL_PATH\n",
650 paths
651 .iter()
652 .map(|path| shell_quote(shell, &path.display().to_string()))
653 .collect::<Vec<_>>()
654 .join(" ")
655 ));
656 }
657 }
658 Shell::Powershell => {
659 out.push_str(
660 "if (-not (Test-Path Env:OSDK_ORIGINAL_PATH_SET)) { $env:OSDK_ORIGINAL_PATH = $env:PATH; $env:OSDK_ORIGINAL_PATH_SET = '1' }\n",
661 );
662 if paths.is_empty() {
663 out.push_str("$env:PATH = $env:OSDK_ORIGINAL_PATH\n");
664 } else {
665 let joined = std::env::join_paths(paths)
666 .unwrap_or_default()
667 .to_string_lossy()
668 .into_owned();
669 out.push_str(&format!(
670 "$env:PATH = {} + [IO.Path]::PathSeparator + $env:OSDK_ORIGINAL_PATH\n",
671 powershell_quote(&joined)
672 ));
673 }
674 }
675 _ => {
676 out.push_str(
677 "if [ -z \"${OSDK_ORIGINAL_PATH_SET+x}\" ]; then export OSDK_ORIGINAL_PATH=\"$PATH\"; export OSDK_ORIGINAL_PATH_SET=1; fi\n",
678 );
679 if paths.is_empty() {
680 out.push_str("export PATH=\"$OSDK_ORIGINAL_PATH\"\n");
681 } else {
682 let joined = paths
683 .iter()
684 .map(|path| path.display().to_string())
685 .collect::<Vec<_>>()
686 .join(":");
687 out.push_str(&format!(
688 "export PATH={}:\"$OSDK_ORIGINAL_PATH\"\n",
689 shell_quote(shell, &joined)
690 ));
691 }
692 }
693 }
694}
695
696fn render_capture_var(shell: Shell, key: &str, out: &mut String) {
697 let original = format!("OSDK_ORIG_{key}");
698 let present = format!("{original}_PRESENT");
699 let set = format!("{original}_SET");
700 match shell {
701 Shell::Fish => out.push_str(&format!(
702 "if not set -q {set}\n if set -q {key}\n set -gx {original} \"${key}\"\n set -gx {present} 1\n else\n set -e {original}\n set -gx {present} 0\n end\n set -gx {set} 1\nend\n"
703 )),
704 Shell::Powershell => out.push_str(&format!(
705 "if (-not (Test-Path Env:{set})) {{ if (Test-Path Env:{key}) {{ $env:{original} = $env:{key}; $env:{present} = '1' }} else {{ Remove-Item Env:{original} -ErrorAction SilentlyContinue; $env:{present} = '0' }}; $env:{set} = '1' }}\n"
706 )),
707 _ => out.push_str(&format!(
708 "if [ -z \"${{{set}+x}}\" ]; then if [ -n \"${{{key}+x}}\" ]; then export {original}=\"${key}\"; export {present}=1; else unset {original}; export {present}=0; fi; export {set}=1; fi\n"
709 )),
710 }
711}
712
713fn render_restore_var(shell: Shell, key: &str, out: &mut String) {
714 let original = format!("OSDK_ORIG_{key}");
715 let present = format!("{original}_PRESENT");
716 let set = format!("{original}_SET");
717 match shell {
718 Shell::Fish => out.push_str(&format!(
719 "if set -q {set}\n if test \"${present}\" = 1\n set -gx {key} \"${original}\"\n else\n set -e {key}\n end\n set -e {original} {present} {set}\nend\n"
720 )),
721 Shell::Powershell => out.push_str(&format!(
722 "if (Test-Path Env:{set}) {{ if ($env:{present} -eq '1') {{ $env:{key} = $env:{original} }} else {{ Remove-Item Env:{key} -ErrorAction SilentlyContinue }}; Remove-Item Env:{original},Env:{present},Env:{set} -ErrorAction SilentlyContinue }}\n"
723 )),
724 _ => out.push_str(&format!(
725 "if [ -n \"${{{set}+x}}\" ]; then if [ \"${{{present}:-0}}\" = 1 ]; then export {key}=\"${original}\"; else unset {key}; fi; unset {original} {present} {set}; fi\n"
726 )),
727 }
728}
729
730fn valid_env_name(key: &str) -> bool {
731 let mut chars = key.chars();
732 matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic())
733 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
734}
735
736fn shell_quote(shell: Shell, s: &str) -> String {
737 match shell {
738 Shell::Powershell => powershell_quote(s),
739 _ => {
740 let escaped = s.replace('\'', r"'\''");
742 format!("'{escaped}'")
743 }
744 }
745}
746
747fn powershell_quote(value: &str) -> String {
748 format!("'{}'", value.replace('\'', "''"))
749}
750
751fn strip_distribution_prefix(spec: &str) -> &str {
753 if let Some((left, right)) = spec.split_once('-') {
754 if !left.is_empty() && left.chars().all(|c| c.is_ascii_alphabetic()) && !right.is_empty() {
755 return right;
756 }
757 }
758 spec
759}
760
761#[cfg(test)]
762mod tests {
763 use std::sync::Arc;
764
765 use super::*;
766 use crate::backend::Backend;
767 use crate::config::{Config, Settings, SourcesConfig};
768 use crate::dirs::Dirs;
769 use crate::platform::Platform;
770 use crate::store::Cas;
771
772 fn test_ctx(root: &std::path::Path, tools: &[(&str, &str)]) -> Ctx {
773 let dirs = Dirs::resolve_from(|key| match key {
774 "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
775 "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
776 "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
777 "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
778 "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
779 _ => None,
780 })
781 .unwrap();
782 dirs.ensure().unwrap();
783 Ctx {
784 cas: Arc::new(Cas::new(dirs.store.clone())),
785 dirs,
786 platform: Platform::current(),
787 config: Config {
788 settings: Settings::default(),
789 sources: SourcesConfig::default(),
790 tools: tools
791 .iter()
792 .map(|(tool, version)| (tool.to_string(), version.to_string()))
793 .collect(),
794 tool_configs: BTreeMap::new(),
795 global_tools: BTreeMap::new(),
796 global_tool_configs: BTreeMap::new(),
797 tool_origins: BTreeMap::new(),
798 aliases: BTreeMap::new(),
799 project_config_path: None,
800 },
801 client: reqwest::Client::new(),
802 show_progress: false,
803 }
804 }
805
806 #[test]
807 fn parse_shell() {
808 assert_eq!("bash".parse::<Shell>().unwrap(), Shell::Bash);
809 assert_eq!("pwsh".parse::<Shell>().unwrap(), Shell::Powershell);
810 assert!("tcsh".parse::<Shell>().is_err());
811 }
812
813 #[test]
814 fn activation_snippet_mentions_hook_env() {
815 let s = activation_script(Shell::Bash, "osdk");
816 assert!(s.contains("hook-env"));
817 assert!(s.contains("PROMPT_COMMAND"));
818
819 let powershell =
820 activation_script(Shell::Powershell, r"C:\Program Files\中文 osdk\osdk.exe");
821 assert!(powershell.contains(r"& 'C:\Program Files\中文 osdk\osdk.exe' hook-env"));
822 assert!(powershell.contains("$script:OsdkHookRunning"));
823 assert!(powershell.contains("finally"));
824 assert!(
825 powershell.find("Invoke-OsdkHook\n").unwrap()
826 < powershell.find("PostCommandLookupAction").unwrap()
827 );
828 }
829
830 #[test]
831 fn deactivation_snippets_remove_hooks_and_restore_path() {
832 let bash = deactivation_script(Shell::Bash);
833 assert!(bash.contains("unset -f _osdk_hook"));
834 assert!(bash.contains("PATH=\"$OSDK_ORIGINAL_PATH\""));
835
836 let zsh = deactivation_script(Shell::Zsh);
837 assert!(zsh.contains("precmd_functions"));
838 assert!(zsh.contains("unfunction _osdk_hook"));
839
840 let fish = deactivation_script(Shell::Fish);
841 assert!(fish.contains("functions -e _osdk_hook"));
842
843 let powershell = deactivation_script(Shell::Powershell);
844 assert!(powershell.contains("PostCommandLookupAction = $null"));
845 assert!(powershell.contains("Remove-Variable OsdkHookRunning"));
846 }
847
848 #[test]
849 fn hook_env_renders_path_and_vars() {
850 let mut set_vars = BTreeMap::new();
851 set_vars.insert("GOROOT".to_string(), "/x/go".to_string());
852 let delta = EnvDelta {
853 path_prepend: vec![PathBuf::from("/x/go/bin")],
854 set_vars,
855 unset_vars: vec!["JAVA_HOME".into()],
856 };
857 let out = render_hook_env(Shell::Bash, &delta);
858 assert!(out.contains("export PATH='/x/go/bin':\"$OSDK_ORIGINAL_PATH\""));
859 assert!(out.contains("export GOROOT='/x/go'"));
860 assert!(out.contains("unset JAVA_HOME"));
861 assert!(out.contains("export OSDK_MANAGED_ENV='GOROOT'"));
862
863 let fish = render_hook_env(Shell::Fish, &delta);
864 assert!(fish.contains("set -gx PATH"));
865 assert!(fish.contains("set -gx GOROOT"));
866 }
867
868 #[test]
869 fn activation_path_prefers_shims_and_keeps_real_bin_fallbacks() {
870 let shims = PathBuf::from("/osdk/shims");
871 let npm = PathBuf::from("/osdk/installs/npm/10.9.0/bin");
872 let pnpm = PathBuf::from("/osdk/installs/pnpm/9.15.0");
873 let node = PathBuf::from("/osdk/installs/node/22.14.0/bin");
874 let go = PathBuf::from("/osdk/installs/go/1.24.0/bin");
875 let mut paths = vec![
876 node.clone(),
877 go.clone(),
878 npm.clone(),
879 shims.clone(),
880 pnpm.clone(),
881 ];
882
883 prioritize_managed_paths(&mut paths, &shims, true);
884
885 assert_eq!(paths, vec![shims, npm, pnpm, node, go]);
886 }
887
888 #[test]
889 fn command_collision_names_follow_target_rules() {
890 #[cfg(windows)]
891 for name in [
892 "node", "node.com", "NODE.EXE", "NoDe.BaT", "node.CMD", "NODE.ps1",
893 ] {
894 assert!(command_name_matches(std::ffi::OsStr::new(name), "node"));
895 }
896 #[cfg(windows)]
897 assert!(command_name_matches(
898 std::ffi::OsStr::new("node.js"),
899 "node"
900 ));
901
902 #[cfg(not(windows))]
903 {
904 assert!(command_name_matches(std::ffi::OsStr::new("node"), "node"));
905 assert!(!command_name_matches(std::ffi::OsStr::new("Node"), "node"));
906 assert!(!command_name_matches(
907 std::ffi::OsStr::new("node.cmd"),
908 "node"
909 ));
910 }
911 }
912
913 #[test]
914 fn project_path_is_deduplicated_against_existing_paths() {
915 let temporary = tempfile::tempdir().unwrap();
916 let project_bin = temporary.path().join(".osdk/npm-bin/generations/test/bin");
917 std::fs::create_dir_all(&project_bin).unwrap();
918 let canonical = dunce::canonicalize(&project_bin).unwrap();
919 let mut paths = vec![project_bin, PathBuf::from("/osdk/shims")];
920
921 prioritize_project_bin(&mut paths, canonical.clone());
922
923 assert_eq!(paths, vec![canonical, PathBuf::from("/osdk/shims")]);
924 }
925
926 #[cfg(unix)]
927 #[test]
928 fn project_path_is_deduplicated_against_a_symlink_alias() {
929 use std::os::unix::fs::symlink;
930
931 let temporary = tempfile::tempdir().unwrap();
932 let project_bin = temporary.path().join(".osdk/npm-bin/generations/test/bin");
933 let alias = temporary.path().join("project-bin");
934 std::fs::create_dir_all(&project_bin).unwrap();
935 symlink(&project_bin, &alias).unwrap();
936 let canonical = dunce::canonicalize(&project_bin).unwrap();
937 let mut paths = vec![alias, PathBuf::from("/osdk/shims")];
938
939 prioritize_project_bin(&mut paths, canonical.clone());
940
941 assert_eq!(paths, vec![canonical, PathBuf::from("/osdk/shims")]);
942 }
943
944 #[test]
945 fn raw_project_npm_bins_are_never_activated() {
946 let temporary = tempfile::tempdir().unwrap();
947 let mut ctx = test_ctx(temporary.path(), &[]);
948 let project = temporary.path().join("project");
949 let bin = project.join("node_modules/.bin");
950 std::fs::create_dir_all(&bin).unwrap();
951 std::fs::write(project.join("package.json"), b"{}").unwrap();
952 std::fs::write(
953 project.join("osdk.toml"),
954 "[tools.\"npm:prettier\"]\nversion = \"3\"\ninstaller = \"aube\"\n",
955 )
956 .unwrap();
957 ctx.config.tools.insert("npm:prettier".into(), "3".into());
958 ctx.config.tool_origins.insert(
959 "npm:prettier".into(),
960 crate::config::ToolConfigOrigin::ProjectConfig(project.join("osdk.toml")),
961 );
962
963 assert!(trusted_project_npm_bin(&ctx, &project).unwrap().is_none());
964 assert!(!compute_env_delta(&ctx, &Registry::new(), &project)
965 .unwrap()
966 .path_prepend
967 .contains(&dunce::canonicalize(bin).unwrap()));
968 }
969
970 #[test]
971 fn project_npm_specs_reject_conflicting_aliases_and_accept_identical_aliases() {
972 let temporary = tempfile::tempdir().unwrap();
973 let project = temporary.path().join("project");
974 std::fs::create_dir_all(&project).unwrap();
975 let config_path = project.join("osdk.toml");
976 std::fs::write(
977 &config_path,
978 "[tools]\n\"tool.alpha\" = \"npm:fixture-cli@1.2.3\"\n\"tool.beta\" = \"npm:fixture-cli@2.0.0\"\n",
979 )
980 .unwrap();
981 let mut ctx = test_ctx(temporary.path(), &[]);
982 ctx.config =
983 crate::config::Config::load(&temporary.path().join("config/config.toml"), &project)
984 .unwrap();
985
986 let error = project_npm_configured_specs(&ctx, &config_path).unwrap_err();
987 let message = error.to_string();
988 assert!(message.contains("npm:fixture-cli"), "{message}");
989 assert!(message.contains("tool.alpha"), "{message}");
990 assert!(message.contains("1.2.3"), "{message}");
991 assert!(message.contains("tool.beta"), "{message}");
992 assert!(message.contains("2.0.0"), "{message}");
993
994 std::fs::write(
995 &config_path,
996 "[tools]\n\"tool.alpha\" = \"npm:fixture-cli@1.2.3\"\n\"tool.beta\" = \"npm:fixture-cli@1.2.3\"\n",
997 )
998 .unwrap();
999 assert_eq!(
1000 project_npm_configured_specs(&ctx, &config_path).unwrap(),
1001 BTreeMap::from([("npm:fixture-cli".into(), "1.2.3".into())])
1002 );
1003 }
1004
1005 #[test]
1006 fn activation_fails_closed_on_corrupt_inventory_with_valid_configured_dynamic_tool() {
1007 let temporary = tempfile::tempdir().unwrap();
1008 let ctx = test_ctx(temporary.path(), &[("npm:fixture-cli", "1.2.3")]);
1009 let project = temporary.path().join("project");
1010 std::fs::create_dir_all(&project).unwrap();
1011
1012 let backend =
1013 crate::backend::npm_package::NpmPackageBackend::from_id("npm:fixture-cli").unwrap();
1014 let mut version = ToolVersion::new(backend.id(), "1.2.3");
1015 version.options.insert(
1016 crate::backend::npm_package::LOCKED_NPM_NODE_VERSION_OPTION.into(),
1017 "1.0.0".into(),
1018 );
1019 let identity = crate::tool::InstallIdentity::new(
1020 backend.id(),
1021 &version.version,
1022 ctx.platform.to_string(),
1023 crate::tool::InstallScope::Isolated,
1024 &version.options,
1025 vec![crate::tool::InstallDependency {
1026 kind: crate::tool::InstallDependencyKind::Runtime,
1027 id: "node".into(),
1028 version: "1.0.0".into(),
1029 identity: None,
1030 }],
1031 BTreeMap::new(),
1032 )
1033 .unwrap();
1034 let valid_root = crate::dirs::InstallLocator::new(&ctx.dirs, identity.clone())
1035 .unwrap()
1036 .install_root()
1037 .to_path_buf();
1038 let mut manifest = crate::inventory::DynamicToolManifest::from_identity(identity).unwrap();
1039 manifest.bins.push(crate::inventory::DynamicToolBin {
1040 name: "fixture-cli".into(),
1041 path: "bin/fixture-cli".into(),
1042 });
1043 manifest.write_atomic(&valid_root).unwrap();
1044 std::fs::create_dir_all(valid_root.join("bin")).unwrap();
1045 std::fs::write(valid_root.join("bin/fixture-cli"), b"fixture").unwrap();
1046 std::fs::write(valid_root.join(".osdk-complete"), b"").unwrap();
1047
1048 let corrupt_root = ctx.dirs.installs.join("github/corrupt/tool/1.0.0");
1049 std::fs::create_dir_all(&corrupt_root).unwrap();
1050 std::fs::write(
1051 crate::inventory::DynamicToolManifest::manifest_path(&corrupt_root),
1052 b"{not valid json",
1053 )
1054 .unwrap();
1055
1056 let error = match compute_env_delta(&ctx, &Registry::new(), &project) {
1057 Ok(_) => panic!("corrupt inventory unexpectedly produced an activation delta"),
1058 Err(error) => error,
1059 };
1060 let message = error.to_string();
1061 assert!(
1062 message.contains("refusing dynamic tool inventory scan"),
1063 "{message}"
1064 );
1065 assert!(message.contains("corrupt"), "{message}");
1066 assert!(
1067 message.contains(crate::inventory::INVENTORY_FILE),
1068 "{message}"
1069 );
1070 }
1071
1072 fn configured_dynamic_fixture(
1073 root: &std::path::Path,
1074 configured_options: BTreeMap<String, crate::config::ToolConfigValue>,
1075 manifest_options: BTreeMap<String, String>,
1076 ) -> (Ctx, std::path::PathBuf, std::path::PathBuf) {
1077 let mut ctx = test_ctx(root, &[("npm:fixture-cli", "1.2.3")]);
1078 let mut configured_options = configured_options;
1079 configured_options.insert(
1080 crate::backend::npm_package::LOCKED_NPM_NODE_VERSION_OPTION.into(),
1081 crate::config::ToolConfigValue::String("1.0.0".into()),
1082 );
1083 ctx.config.tool_configs.insert(
1084 "npm:fixture-cli".into(),
1085 crate::config::ToolConfigEntry::structured("1.2.3", configured_options),
1086 );
1087 let backend =
1088 crate::backend::npm_package::NpmPackageBackend::from_id("npm:fixture-cli").unwrap();
1089 let mut version = ToolVersion::new(backend.id(), "1.2.3");
1090 version.options = manifest_options;
1091 version.options.insert(
1092 crate::backend::npm_package::LOCKED_NPM_NODE_VERSION_OPTION.into(),
1093 "1.0.0".into(),
1094 );
1095 let identity = crate::tool::InstallIdentity::new(
1096 backend.id(),
1097 &version.version,
1098 ctx.platform.to_string(),
1099 crate::tool::InstallScope::Isolated,
1100 &version.options,
1101 vec![crate::tool::InstallDependency {
1102 kind: crate::tool::InstallDependencyKind::Runtime,
1103 id: "node".into(),
1104 version: "1.0.0".into(),
1105 identity: None,
1106 }],
1107 BTreeMap::new(),
1108 )
1109 .unwrap();
1110 let install_root = crate::dirs::InstallLocator::new(&ctx.dirs, identity.clone())
1111 .unwrap()
1112 .install_root()
1113 .to_path_buf();
1114 let bin = install_root.join("bin/fixture-cli");
1115 std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1116 std::fs::write(&bin, b"fixture").unwrap();
1117 std::fs::write(install_root.join(".osdk-complete"), b"").unwrap();
1118 let mut manifest = crate::inventory::DynamicToolManifest::from_identity(identity).unwrap();
1119 manifest.bins.push(crate::inventory::DynamicToolBin {
1120 name: "fixture-cli".into(),
1121 path: "bin/fixture-cli".into(),
1122 });
1123 manifest.write_atomic(&install_root).unwrap();
1124 (ctx, install_root, bin)
1125 }
1126
1127 fn activation_error(ctx: &Ctx, project: &std::path::Path) -> String {
1128 match compute_env_delta(ctx, &Registry::new(), project) {
1129 Ok(_) => panic!("invalid dynamic install unexpectedly produced an activation delta"),
1130 Err(error) => error.to_string(),
1131 }
1132 }
1133
1134 #[test]
1135 fn activation_rejects_configured_dynamic_install_without_inventory() {
1136 let temporary = tempfile::tempdir().unwrap();
1137 let project = temporary.path().join("project");
1138 std::fs::create_dir_all(&project).unwrap();
1139 let (ctx, install_root, bin) =
1140 configured_dynamic_fixture(temporary.path(), BTreeMap::new(), BTreeMap::new());
1141 std::fs::remove_file(crate::inventory::DynamicToolManifest::manifest_path(
1142 &install_root,
1143 ))
1144 .unwrap();
1145
1146 let message = activation_error(&ctx, &project);
1147 assert!(
1148 message.contains("missing or invalid install identity"),
1149 "{message}"
1150 );
1151 assert!(message.contains("reinstall"), "{message}");
1152 assert!(
1153 bin.exists(),
1154 "fixture must retain the otherwise runnable bin"
1155 );
1156 }
1157
1158 #[test]
1159 fn activation_rejects_configured_dynamic_install_without_completion_marker() {
1160 let temporary = tempfile::tempdir().unwrap();
1161 let project = temporary.path().join("project");
1162 std::fs::create_dir_all(&project).unwrap();
1163 let (ctx, install_root, bin) =
1164 configured_dynamic_fixture(temporary.path(), BTreeMap::new(), BTreeMap::new());
1165 std::fs::remove_file(install_root.join(".osdk-complete")).unwrap();
1166
1167 let message = activation_error(&ctx, &project);
1168 assert!(
1169 message.contains("no complete selected install"),
1170 "{message}"
1171 );
1172 assert!(message.contains("reinstall"), "{message}");
1173 assert!(
1174 bin.exists(),
1175 "fixture must retain the otherwise runnable bin"
1176 );
1177 }
1178
1179 #[test]
1180 fn activation_rejects_legacy_dynamic_inventory() {
1181 let temporary = tempfile::tempdir().unwrap();
1182 let project = temporary.path().join("project");
1183 std::fs::create_dir_all(&project).unwrap();
1184 let (ctx, install_root, _) =
1185 configured_dynamic_fixture(temporary.path(), BTreeMap::new(), BTreeMap::new());
1186 std::fs::remove_file(crate::inventory::DynamicToolManifest::manifest_path(
1187 &install_root,
1188 ))
1189 .unwrap();
1190 std::fs::write(
1191 install_root.join(crate::inventory::LEGACY_INVENTORY_FILE),
1192 r#"{"schema":2,"id":"npm:fixture-cli","version":"1.2.3","bins":[{"name":"fixture-cli","path":"bin/fixture-cli"}]}"#,
1193 )
1194 .unwrap();
1195
1196 let message = activation_error(&ctx, &project);
1197 assert!(
1198 message.contains("missing or invalid install identity"),
1199 "{message}"
1200 );
1201 assert!(message.contains("reinstall"), "{message}");
1202 }
1203
1204 #[test]
1205 fn activation_rejects_configured_dynamic_option_mismatch() {
1206 let temporary = tempfile::tempdir().unwrap();
1207 let project = temporary.path().join("project");
1208 std::fs::create_dir_all(&project).unwrap();
1209 let (ctx, _, _) = configured_dynamic_fixture(
1210 temporary.path(),
1211 BTreeMap::from([(
1212 "installer".into(),
1213 crate::config::ToolConfigValue::String("aube".into()),
1214 )]),
1215 BTreeMap::from([("installer".into(), "npm".into())]),
1216 );
1217
1218 let message = activation_error(&ctx, &project);
1219 assert!(
1220 message.contains("no complete selected install"),
1221 "{message}"
1222 );
1223 assert!(message.contains("reinstall"), "{message}");
1224 }
1225
1226 #[test]
1227 fn activation_rejects_configured_dynamic_version_mismatch() {
1228 let temporary = tempfile::tempdir().unwrap();
1229 let project = temporary.path().join("project");
1230 std::fs::create_dir_all(&project).unwrap();
1231 let (ctx, install_root, _) =
1232 configured_dynamic_fixture(temporary.path(), BTreeMap::new(), BTreeMap::new());
1233 let old_manifest = crate::inventory::DynamicToolManifest::load(&install_root).unwrap();
1234 let identity = crate::tool::InstallIdentity::new(
1235 &old_manifest.identity.tool,
1236 "9.9.9",
1237 &old_manifest.identity.platform,
1238 old_manifest.identity.scope,
1239 &old_manifest.identity.material_options,
1240 old_manifest.identity.dependencies,
1241 old_manifest.identity.materials,
1242 )
1243 .unwrap();
1244 let mut manifest = crate::inventory::DynamicToolManifest::from_identity(identity).unwrap();
1245 manifest.bins = old_manifest.bins;
1246 let path = crate::inventory::DynamicToolManifest::manifest_path(&install_root);
1247 std::fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1248
1249 let message = activation_error(&ctx, &project);
1250 assert!(
1251 message.contains("refusing dynamic tool inventory scan"),
1252 "{message}"
1253 );
1254 assert!(message.contains("identity root"), "{message}");
1255 }
1256
1257 #[test]
1258 fn http_activation_uses_inventory_offline_and_rejects_receipt_tampering() {
1259 let temporary = tempfile::tempdir().unwrap();
1260 let project = temporary.path().join("project");
1261 std::fs::create_dir_all(&project).unwrap();
1262 let backend = "http:https://downloads.example.test/tool-{version}";
1263 let receipt_url = "https://downloads.example.test/tool-1.2.3";
1264 let digest = "a".repeat(64);
1265 let mut ctx = test_ctx(temporary.path(), &[(backend, "1.2.3")]);
1266 let options: BTreeMap<String, String> = BTreeMap::from([
1267 ("sha256".into(), digest.clone()),
1268 ("kind".into(), "file".into()),
1269 ("rename".into(), "fixture-http".into()),
1270 ]);
1271 ctx.config.tool_configs.insert(
1272 backend.into(),
1273 crate::config::ToolConfigEntry::structured(
1274 "1.2.3",
1275 options
1276 .iter()
1277 .map(|(key, value)| {
1278 (
1279 key.clone(),
1280 crate::config::ToolConfigValue::String(value.clone()),
1281 )
1282 })
1283 .collect(),
1284 ),
1285 );
1286 ctx.config.settings.offline = true;
1287 let mut version = ToolVersion::new(backend, "1.2.3");
1288 version.options = options;
1289 version.options.extend(BTreeMap::from([
1290 (
1291 crate::pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
1292 receipt_url.into(),
1293 ),
1294 (
1295 crate::pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
1296 "tool-1.2.3".into(),
1297 ),
1298 (
1299 crate::pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
1300 format!("sha256:{digest}"),
1301 ),
1302 ]));
1303 let locator = crate::backend::http::HttpBackend::install_locator_for(
1304 &ctx.dirs,
1305 ctx.platform,
1306 backend,
1307 &version,
1308 )
1309 .unwrap();
1310 let root = locator.install_root();
1311 std::fs::create_dir_all(root.join("bin")).unwrap();
1312 std::fs::write(root.join("bin/fixture-http"), b"fixture").unwrap();
1313 let mut manifest =
1314 crate::inventory::DynamicToolManifest::from_identity(locator.identity().clone())
1315 .unwrap();
1316 manifest.bins.push(crate::inventory::DynamicToolBin {
1317 name: "fixture-http".into(),
1318 path: "bin/fixture-http".into(),
1319 });
1320 manifest.write_atomic(root).unwrap();
1321 std::fs::write(
1322 root.join(".osdk-artifact.json"),
1323 format!(
1324 r#"{{"url":{receipt_url:?},"file_name":"tool-1.2.3","checksum":"sha256:{digest}"}}"#
1325 ),
1326 )
1327 .unwrap();
1328 std::fs::write(root.join(".osdk-complete"), b"").unwrap();
1329 std::fs::create_dir_all(ctx.dirs.shims()).unwrap();
1330 std::fs::write(ctx.dirs.shims().join("fixture-http"), b"shim").unwrap();
1331
1332 let delta = compute_env_delta(&ctx, &Registry::new(), &project).unwrap();
1333 let expected_bin = root.join("bin");
1334 assert!(delta
1335 .path_prepend
1336 .iter()
1337 .any(|path| same_existing_path(path, &expected_bin)));
1338
1339 let receipt_path = root.join(".osdk-artifact.json");
1340 let tampered = std::fs::read_to_string(&receipt_path).unwrap().replace(
1341 receipt_url,
1342 "https://downloads.example.test/substitute-1.2.3",
1343 );
1344 std::fs::write(receipt_path, tampered).unwrap();
1345 let message = activation_error(&ctx, &project);
1346 assert!(message.contains("receipt"), "{message}");
1347 }
1348
1349 #[test]
1350 fn activation_path_does_not_add_shims_without_an_active_runtime() {
1351 let mut no_active_bins = Vec::new();
1352 prioritize_managed_paths(
1353 &mut no_active_bins,
1354 std::path::Path::new("/osdk/shims"),
1355 false,
1356 );
1357 assert!(no_active_bins.is_empty());
1358 }
1359
1360 #[test]
1361 fn activation_path_does_not_add_missing_shims() {
1362 let node = PathBuf::from("/osdk/installs/node/22.14.0/bin");
1363 let mut paths = vec![node.clone()];
1364 prioritize_managed_paths(&mut paths, std::path::Path::new("/osdk/shims"), false);
1365 assert_eq!(paths, vec![node]);
1366 }
1367
1368 #[test]
1369 fn empty_delta_restores_original_path() {
1370 let delta = EnvDelta {
1371 path_prepend: Vec::new(),
1372 set_vars: BTreeMap::new(),
1373 unset_vars: vec!["GOROOT".into()],
1374 };
1375 let out = render_hook_env(Shell::Bash, &delta);
1376 assert!(out.contains("export PATH=\"$OSDK_ORIGINAL_PATH\""));
1377 assert!(out.contains("unset GOROOT"));
1378 assert!(out.contains("unset OSDK_MANAGED_ENV"));
1379 }
1380}