Skip to content

Commit d4fa5ec

Browse files
authored
fix: find PATH case-insensitively on Windows (#29)
Make sure to find the case-insensitive PATH for inserting `node_modules/.bin`
1 parent 33381fc commit d4fa5ec

File tree

2 files changed

+204
-16
lines changed

2 files changed

+204
-16
lines changed

crates/vite_str/src/lib.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,6 @@ impl Str {
7171
pub fn push_str(&mut self, s: &str) {
7272
self.0.push_str(s);
7373
}
74-
75-
pub fn to_uppercase(&self) -> Self {
76-
Self(self.0.to_uppercase())
77-
}
78-
79-
pub fn to_lowercase(&self) -> Self {
80-
Self(self.0.to_lowercase())
81-
}
8274
}
8375

8476
impl AsRef<str> for Str {

crates/vite_task/src/execute.rs

Lines changed: 204 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,7 @@ impl TaskEnvs {
265265
GlobPatternSet::new(task.config.envs.iter().filter(|s| !s.starts_with('!')))?;
266266
let sensitive_patterns = GlobPatternSet::new(SENSITIVE_PATTERNS)?;
267267
for (name, value) in &all_envs {
268-
let upper_name = name.to_uppercase();
269-
if !envs_without_pass_through_patterns.is_match(&upper_name) {
268+
if !envs_without_pass_through_patterns.is_match(name) {
270269
continue;
271270
}
272271
let Some(value) = value.to_str() else {
@@ -275,7 +274,7 @@ impl TaskEnvs {
275274
value: value.to_os_string(),
276275
});
277276
};
278-
let value: Str = if sensitive_patterns.is_match(&upper_name) {
277+
let value: Str = if sensitive_patterns.is_match(name) {
279278
let mut hasher = Sha256::new();
280279
hasher.update(value.as_bytes());
281280
format!("sha256:{:x}", hasher.finalize()).into()
@@ -311,13 +310,31 @@ impl TaskEnvs {
311310
all_envs.insert("VITE_TASK_EXECUTION_ENV".into(), Arc::<OsStr>::from(OsStr::new("1")));
312311

313312
// Add node_modules/.bin to PATH
314-
let env_path =
315-
all_envs.entry("PATH".into()).or_insert_with(|| Arc::<OsStr>::from(OsStr::new("")));
316-
let paths = split_paths(env_path);
313+
// On Windows, environment variable names are case-insensitive (e.g., "PATH", "Path", "path" are all the same)
314+
// However, Rust's HashMap keys are case-sensitive, so we need to find the existing PATH variable
315+
// regardless of its casing to avoid creating duplicate PATH entries with different casings.
316+
// For example, if the system has "Path", we should use that instead of creating a new "PATH" entry.
317+
let env_path = {
318+
if cfg!(windows)
319+
&& let Some(existing_path) = all_envs.iter_mut().find_map(|(name, value)| {
320+
if name.eq_ignore_ascii_case("path") { Some(value) } else { None }
321+
})
322+
{
323+
// Found existing PATH variable (with any casing), use it
324+
existing_path
325+
} else {
326+
// On Unix or no existing PATH on Windows, create/get "PATH" entry
327+
all_envs.entry("PATH".into()).or_insert_with(|| Arc::<OsStr>::from(OsStr::new("")))
328+
}
329+
};
330+
let paths = split_paths(env_path).filter(|path| !path.as_os_str().is_empty());
331+
332+
const NODE_MODULES_DOT_BIN: &str =
333+
if cfg!(windows) { "node_modules\\.bin" } else { "node_modules/.bin" };
317334

318335
let node_modules_bin_paths = [
319-
base_dir.join(&task.config.cwd).join("node_modules/.bin").into_path_buf(),
320-
base_dir.join(&task.config_dir).join("node_modules/.bin").into_path_buf(),
336+
base_dir.join(&task.config.cwd).join(NODE_MODULES_DOT_BIN).into_path_buf(),
337+
base_dir.join(&task.config_dir).join(NODE_MODULES_DOT_BIN).into_path_buf(),
321338
];
322339
*env_path = join_paths(node_modules_bin_paths.into_iter().chain(paths))?.into();
323340

@@ -887,4 +904,183 @@ mod tests {
887904
assert!(all_envs.contains_key("app1_name"));
888905
assert!(all_envs.contains_key("app2_name"));
889906
}
907+
908+
#[test]
909+
#[cfg(windows)]
910+
fn test_windows_path_case_insensitive_mixed_case() {
911+
use crate::{
912+
collections::HashSet,
913+
config::{ResolvedTaskConfig, TaskCommand, TaskConfig},
914+
};
915+
916+
let task_config = TaskConfig {
917+
command: TaskCommand::ShellScript("echo test".into()),
918+
cwd: RelativePathBuf::default(),
919+
cacheable: true,
920+
inputs: HashSet::new(),
921+
envs: HashSet::new(),
922+
pass_through_envs: HashSet::new(),
923+
fingerprint_ignores: None,
924+
};
925+
let resolved =
926+
ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config: task_config };
927+
928+
// Mock environment with mixed case "Path" (common on Windows)
929+
let mock_envs = vec![
930+
(OsString::from("Path"), OsString::from("C:\\existing\\path")),
931+
(OsString::from("OTHER_VAR"), OsString::from("value")),
932+
];
933+
934+
let base_dir = AbsolutePath::new("C:\\workspace\\packages\\app").unwrap();
935+
936+
let result = TaskEnvs::resolve(mock_envs.into_iter(), &base_dir, &resolved).unwrap();
937+
938+
let all_envs = result.all_envs;
939+
940+
// Verify that the original "Path" casing is preserved, not "PATH"
941+
assert!(all_envs.contains_key("Path"));
942+
assert!(!all_envs.contains_key("PATH"));
943+
944+
// Verify the complete PATH value matches expected
945+
let path_value = all_envs.get("Path").unwrap();
946+
assert_eq!(
947+
path_value.as_ref(),
948+
OsStr::new(
949+
"C:\\workspace\\packages\\app\\node_modules\\.bin;C:\\workspace\\packages\\app\\node_modules\\.bin;C:\\existing\\path"
950+
)
951+
);
952+
953+
// Verify no duplicate PATH entry was created
954+
let path_like_keys: Vec<_> =
955+
all_envs.keys().filter(|k| k.eq_ignore_ascii_case("path")).collect();
956+
assert_eq!(path_like_keys.len(), 1);
957+
}
958+
959+
#[test]
960+
#[cfg(windows)]
961+
fn test_windows_path_case_insensitive_uppercase() {
962+
use crate::{
963+
collections::HashSet,
964+
config::{ResolvedTaskConfig, TaskCommand, TaskConfig},
965+
};
966+
967+
let task_config = TaskConfig {
968+
command: TaskCommand::ShellScript("echo test".into()),
969+
cwd: RelativePathBuf::default(),
970+
cacheable: true,
971+
inputs: HashSet::new(),
972+
envs: HashSet::new(),
973+
pass_through_envs: HashSet::new(),
974+
fingerprint_ignores: None,
975+
};
976+
let resolved =
977+
ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config: task_config };
978+
979+
// Mock environment with uppercase "PATH"
980+
let mock_envs = vec![
981+
(OsString::from("PATH"), OsString::from("C:\\existing\\path")),
982+
(OsString::from("OTHER_VAR"), OsString::from("value")),
983+
];
984+
985+
let base_dir = AbsolutePath::new("C:\\workspace\\packages\\app").unwrap();
986+
987+
let result = TaskEnvs::resolve(mock_envs.into_iter(), &base_dir, &resolved).unwrap();
988+
989+
let all_envs = result.all_envs;
990+
991+
// Verify the complete PATH value matches expected
992+
let path_value = all_envs.get("PATH").unwrap();
993+
assert_eq!(
994+
path_value.as_ref(),
995+
OsStr::new(
996+
"C:\\workspace\\packages\\app\\node_modules\\.bin;C:\\workspace\\packages\\app\\node_modules\\.bin;C:\\existing\\path"
997+
)
998+
);
999+
}
1000+
1001+
#[test]
1002+
#[cfg(windows)]
1003+
fn test_windows_path_created_when_missing() {
1004+
use crate::{
1005+
collections::HashSet,
1006+
config::{ResolvedTaskConfig, TaskCommand, TaskConfig},
1007+
};
1008+
1009+
let task_config = TaskConfig {
1010+
command: TaskCommand::ShellScript("echo test".into()),
1011+
cwd: RelativePathBuf::default(),
1012+
cacheable: true,
1013+
inputs: HashSet::new(),
1014+
envs: HashSet::new(),
1015+
pass_through_envs: HashSet::new(),
1016+
fingerprint_ignores: None,
1017+
};
1018+
let resolved =
1019+
ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config: task_config };
1020+
1021+
// Mock environment without any PATH variable
1022+
let mock_envs = vec![(OsString::from("OTHER_VAR"), OsString::from("value"))];
1023+
1024+
let base_dir = AbsolutePath::new("C:\\workspace\\packages\\app").unwrap();
1025+
1026+
let result = TaskEnvs::resolve(mock_envs.into_iter(), &base_dir, &resolved).unwrap();
1027+
1028+
let all_envs = result.all_envs;
1029+
1030+
// Verify the complete PATH value matches expected (only node_modules/.bin paths, no existing path)
1031+
let path_value = all_envs.get("PATH").unwrap();
1032+
assert_eq!(
1033+
path_value.as_ref(),
1034+
OsStr::new(
1035+
"C:\\workspace\\packages\\app\\node_modules\\.bin;C:\\workspace\\packages\\app\\node_modules\\.bin"
1036+
)
1037+
);
1038+
}
1039+
1040+
#[test]
1041+
#[cfg(unix)]
1042+
fn test_unix_path_case_sensitive() {
1043+
use crate::{
1044+
collections::HashSet,
1045+
config::{ResolvedTaskConfig, TaskCommand, TaskConfig},
1046+
};
1047+
1048+
let task_config = TaskConfig {
1049+
command: TaskCommand::ShellScript("echo test".into()),
1050+
cwd: RelativePathBuf::default(),
1051+
cacheable: true,
1052+
inputs: HashSet::new(),
1053+
envs: HashSet::new(),
1054+
pass_through_envs: HashSet::new(),
1055+
fingerprint_ignores: None,
1056+
};
1057+
let resolved =
1058+
ResolvedTaskConfig { config_dir: RelativePathBuf::default(), config: task_config };
1059+
1060+
// Mock environment with "PATH" in uppercase (standard on Unix)
1061+
let mock_envs = vec![
1062+
(OsString::from("PATH"), OsString::from("/existing/path")),
1063+
(OsString::from("OTHER_VAR"), OsString::from("value")),
1064+
];
1065+
1066+
let base_dir = AbsolutePath::new("/workspace/packages/app").unwrap();
1067+
1068+
let result = TaskEnvs::resolve(mock_envs.into_iter(), &base_dir, &resolved).unwrap();
1069+
1070+
let all_envs = result.all_envs;
1071+
1072+
// Verify "PATH" exists and the complete value matches expected
1073+
let path_value = all_envs.get("PATH").unwrap();
1074+
assert_eq!(
1075+
path_value.as_ref(),
1076+
OsStr::new(
1077+
"/workspace/packages/app/node_modules/.bin:/workspace/packages/app/node_modules/.bin:/existing/path"
1078+
)
1079+
);
1080+
1081+
// Verify that on Unix, the code uses exact "PATH" match (case-sensitive)
1082+
// This is a regression test to ensure Windows case-insensitive logic doesn't affect Unix
1083+
assert!(!all_envs.contains_key("Path"));
1084+
assert!(!all_envs.contains_key("path"));
1085+
}
8901086
}

0 commit comments

Comments
 (0)