Repository: denoland/deno (language: rust)

Issue:
Bug: exec's shell arg nolonger search path : no path to search and provided name is not an absolute path
Version: Deno 2.1.7

my code that worked in old version:
```js
import { exec as base_exec } from 'node:child_process'
import { promisify } from 'node:util'
const exec = promisify(base_exec)

// if pwsh.exe available, use it, else use powershell.exe
let pwshavailable = Boolean(await exec('1', { 'shell': 'pwsh.exe' }).catch(() => false))

export function pwsh_exec(code) {
	return exec(code, { 'shell': pwshavailable ? 'pwsh.exe' : 'powershell.exe' })
}
export function powershell_exec(code) {
	return exec(code, { 'shell': 'powershell.exe' })
}
export function bash_exec(code) {
	return exec(code, { 'shell': '/bin/bash' })
}
export {
	exec
}

export function where_command(command) {
	if (process.platform === 'win32')
		return pwsh_exec(`Get-Command -Name ${command} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Definition`).then(result => result.stdout.trim())
	else
		return bash_exec(`which ${command}`).then(result => result.stdout.trim())
}
```
result in new version:

![Image](https://github.com/user-attachments/assets/8d502311-4803-4cd5-b994-1f58de1a68b6)

reproduce code: 
```js
import { exec as base_exec } from 'node:child_process'
import { promisify } from 'node:util'
const exec = promisify(base_exec)
await exec('1', { 'shell': 'powershell.exe' })
```


Discussion / hints:
![Image](https://github.com/user-attachments/assets/1dfb6aa4-ab39-49de-be7a-7a616bf904bb)
'm sure both pwsh and powershell are in my path, but somehow deno doesn't seem to be searching for it.
Can you do me a favour and update the issue description so that the bug can be reproduced by just running the code in the issue? That will make it easier to reproduce for fixing the issue.
> Can you do me a favour and update the issue description so that the bug can be reproduced by just running the code in the issue? That will make it easier to reproduce for fixing the issue.

added!

Fix patch:
diff --git a/ext/process/lib.rs b/ext/process/lib.rs
index 24985a8048236b..f568d283ae7eeb 100644
--- a/ext/process/lib.rs
+++ b/ext/process/lib.rs
@@ -424,7 +424,7 @@ fn create_command(
 
   command.current_dir(run_env.cwd);
   command.env_clear();
-  command.envs(run_env.envs);
+  command.envs(run_env.envs.into_iter().map(|(k, v)| (k.into_inner(), v)));
 
   #[cfg(unix)]
   if let Some(gid) = args.gid {
@@ -697,8 +697,60 @@ fn compute_run_cmd_and_check_permissions(
   Ok((cmd, run_env))
 }
 
+#[derive(Debug)]
+struct EnvVarKey {
+  inner: OsString,
+  // Windows treats env vars as case insensitive, so use
+  // a normalized value for comparisons instead of the raw
+  // case sensitive value
+  #[cfg(windows)]
+  normalized: OsString,
+}
+
+impl EnvVarKey {
+  pub fn new(value: OsString) -> Self {
+    Self {
+      #[cfg(windows)]
+      normalized: value.to_ascii_uppercase(),
+      inner: value,
+    }
+  }
+
+  pub fn from_str(value: &str) -> Self {
+    Self::new(OsString::from(value))
+  }
+
+  pub fn into_inner(self) -> OsString {
+    self.inner
+  }
+
+  pub fn comparison_value(&self) -> &OsString {
+    #[cfg(windows)]
+    {
+      &self.normalized
+    }
+    #[cfg(not(windows))]
+    {
+      &self.inner
+    }
+  }
+}
+
+impl std::hash::Hash for EnvVarKey {
+  fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+    self.comparison_value().hash(state);
+  }
+}
+
+impl std::cmp::Eq for EnvVarKey {}
+impl std::cmp::PartialEq for EnvVarKey {
+  fn eq(&self, other: &Self) -> bool {
+    self.comparison_value() == other.comparison_value()
+  }
+}
+
 struct RunEnv {
-  envs: HashMap<OsString, OsString>,
+  envs: HashMap<EnvVarKey, OsString>,
   cwd: PathBuf,
 }
 
@@ -721,30 +773,14 @@ fn compute_run_env(
   let envs = if arg_clear_env {
     arg_envs
       .iter()
-      .map(|(k, v)| (OsString::from(k), OsString::from(v)))
+      .map(|(k, v)| (EnvVarKey::from_str(k), OsString::from(v)))
       .collect()
   } else {
     let mut envs = std::env::vars_os()
-      .map(|(k, v)| {
-        (
-          if cfg!(windows) {
-            k.to_ascii_uppercase()
-          } else {
-            k
-          },
-          v,
-        )
-      })
+      .map(|(k, v)| (EnvVarKey::new(k), v))
       .collect::<HashMap<_, _>>();
     for (key, value) in arg_envs {
-      envs.insert(
-        OsString::from(if cfg!(windows) {
-          key.to_ascii_uppercase()
-        } else {
-          key.clone()
-        }),
-        OsString::from(value.clone()),
-      );
+      envs.insert(EnvVarKey::from_str(key), OsString::from(value));
     }
     envs
   };
@@ -758,7 +794,7 @@ fn resolve_cmd(cmd: &str, env: &RunEnv) -> Result<PathBuf, ProcessError> {
   if is_path {
     Ok(resolve_path(cmd, &env.cwd))
   } else {
-    let path = env.envs.get(&OsString::from("PATH"));
+    let path = env.envs.get(&EnvVarKey::new(OsString::from("PATH")));
     match which::which_in(cmd, path, &env.cwd) {
       Ok(cmd) => Ok(cmd),
       Err(which::Error::CannotFindBinaryPath) => {
@@ -815,10 +851,18 @@ fn check_run_permission(
 
 fn get_requires_allow_all_env_vars(env: &RunEnv) -> Vec<&str> {
   fn requires_allow_all(key: &str) -> bool {
+    fn starts_with_ignore_case(key: &str, search_value: &str) -> bool {
+      if let Some((key, _)) = key.split_at_checked(search_value.len()) {
+        search_value.eq_ignore_ascii_case(key)
+      } else {
+        false
+      }
+    }
+
     let key = key.trim();
     // we could be more targted here, but there are quite a lot of
     // LD_* and DYLD_* env variables
-    key.starts_with("LD_") || key.starts_with("DYLD_")
+    starts_with_ignore_case(key, "LD_") || starts_with_ignore_case(key, "DYLD_")
   }
 
   fn is_empty(value: &OsString) -> bool {
@@ -830,7 +874,7 @@ fn get_requires_allow_all_env_vars(env: &RunEnv) -> Vec<&str> {
     .envs
     .iter()
     .filter_map(|(k, v)| {
-      let key = k.to_str()?;
+      let key = k.comparison_value().to_str()?;
       if requires_allow_all(key) && !is_empty(v) {
         Some(key)
       } else {
@@ -993,7 +1037,7 @@ mod deprecated {
 
     c.env_clear();
     for (key, value) in run_env.envs {
-      c.env(key, value);
+      c.env(key.inner, value);
     }
 
     #[cfg(unix)]


Test patch:
diff --git a/tests/unit/command_test.ts b/tests/unit/command_test.ts
index 268b0c25baf1f2..fa941df0dce5e1 100644
--- a/tests/unit/command_test.ts
+++ b/tests/unit/command_test.ts
@@ -1074,3 +1074,22 @@ Deno.test(async function outputWhenManuallyConsumingStreams() {
   assertEquals(status.stdout, new Uint8Array());
   assertEquals(status.stderr, new Uint8Array());
 });
+
+Deno.test(
+  {
+    ignore: Deno.build.os !== "windows",
+    permissions: { run: ["cmd"], read: true, env: true },
+  },
+  async function envCaseInsensitiveWindows() {
+    const command = new Deno.Command("cmd", {
+      args: ["/d", "/s", "/c", "echo", "1"],
+      env: {
+        // notice Path is not PATH
+        Path: Deno.env.get("PATH")!,
+      },
+      clearEnv: true,
+    });
+    const child = await command.output();
+    assertEquals(child.success, true);
+  },
+);