Skip to content

Commit 10166f5

Browse files
committed
fix(command): restrict .cmd to PowerShell rewrite to vp-managed shims
Per the Codex adversarial review: the previous implementation rewrote any `.cmd` with a sibling `.ps1` to spawn through `PowerShell -ExecutionPolicy Bypass`. That covers vp's own pm shims but also silently retargets unrelated PATH commands — system tools, globally-installed npm wrappers, third-party CLIs whose `.cmd`/`.ps1` wrappers may not be semantically equivalent — and broadens execution semantics on locked-down hosts that intentionally block unsigned `.ps1` execution. Gate the rewrite on `resolved.starts_with($VP_HOME)`, where `$VP_HOME` is the vp install root (`~/.vite-plus` by default). That covers the legitimate cases (`$VP_HOME/js_runtime/node/<v>/npm.cmd` and `$VP_HOME/package_manager/<pm>/<v>/<pm>/bin/<pm>.cmd`) and leaves anything else alone. `$VP_HOME` is read once via `vite_shared::get_vp_home()` and cached in a `LazyLock`. Adds a regression test (`returns_none_for_cmd_outside_vp_home`) that puts a matching `.cmd`+`.ps1` pair outside the scope and asserts no rewrite happens.
1 parent 5e0e08b commit 10166f5

3 files changed

Lines changed: 95 additions & 34 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_command/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ tracing = { workspace = true }
1515
vite_error = { workspace = true }
1616
vite_path = { workspace = true }
1717
vite_powershell = { workspace = true }
18+
vite_shared = { workspace = true }
1819
which = { workspace = true, features = ["tracing"] }
1920

2021
[target.'cfg(not(target_os = "windows"))'.dependencies]
Lines changed: 93 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
1-
//! Windows-specific: when a resolved binary is a `.cmd` shim with a sibling
2-
//! `.ps1`, rewrite the spawn to go through `powershell.exe -File <sibling.ps1>`.
1+
//! Windows-specific: when a vp-managed package-manager `.cmd` shim has a
2+
//! sibling `.ps1`, rewrite the spawn to go through
3+
//! `powershell.exe -File <sibling.ps1>`.
34
//!
45
//! Running a `.cmd` from any shell makes `cmd.exe` prompt "Terminate batch
56
//! job (Y/N)?" on Ctrl+C, which leaves the terminal corrupt. Routing through
67
//! `PowerShell` sidesteps the prompt and lets Ctrl+C propagate cleanly.
78
//!
8-
//! Unlike the task-layer rewrite (`vite_task_plan::ps1_shim`, scoped to
9-
//! `node_modules/.bin/*.cmd` inside the workspace), this one applies to any
10-
//! `.cmd` whose `.ps1` sibling exists. Package manager shims (`npm.cmd`,
11-
//! `pnpm.cmd`, `yarn.cmd`, `npx.cmd`) live in `~/.vite-plus/js_runtime/...`
12-
//! or system PATH — outside any `node_modules/.bin` — so the structural
13-
//! check there is too narrow for this code path.
9+
//! The rewrite is intentionally **scoped to paths inside `$VP_HOME`**
10+
//! (`~/.vite-plus` by default). vp's managed shims live there:
11+
//! - `$VP_HOME/js_runtime/node/<ver>/{npm,npx}.cmd` (npm/npx shipped with
12+
//! the managed Node.js),
13+
//! - `$VP_HOME/package_manager/<pm>/<ver>/<pm>/bin/<pm>.cmd` (downloaded
14+
//! pnpm/yarn/bun).
1415
//!
15-
//! The cross-platform primitives (`POWERSHELL_PREFIX`, `powershell_host`,
16-
//! `find_ps1_sibling`) live in the `vite_powershell` crate and are shared
17-
//! with `vite_task_plan::ps1_shim`. This module composes them with vp's
18-
//! own conventions: absolute `.ps1` path in args (no cache fingerprint to
19-
//! keep portable) and `Vec<OsString>` return type (matches the spawn API).
16+
//! Anything outside `$VP_HOME` — system tools, globally-installed npm
17+
//! shims, third-party CLIs whose `.cmd` and `.ps1` wrappers may not be
18+
//! semantically equivalent, hosts that intentionally block unsigned
19+
//! `.ps1` execution — is left alone. Stricter scoping means the prior
20+
//! `.cmd` path still runs there, but that matches the pre-fix status quo
21+
//! and avoids broadening execution semantics for unrelated commands.
22+
//!
23+
//! The task-layer rewrite (`vite_task_plan::ps1_shim`) is scoped
24+
//! differently — to `node_modules/.bin/*.cmd` inside the workspace — and
25+
//! covers `vp run <script>`. The two scopes don't overlap.
2026
//!
2127
//! See <https://github.com/voidzero-dev/vite-plus/issues/1489>
2228
//! and <https://github.com/voidzero-dev/vite-plus/issues/1176>.
@@ -26,32 +32,51 @@ use std::{ffi::OsString, sync::Arc};
2632
use vite_path::{AbsolutePath, AbsolutePathBuf};
2733
use vite_powershell::{POWERSHELL_PREFIX, find_ps1_sibling, powershell_host};
2834

29-
/// Rewrite a resolved `.cmd` invocation to go through PowerShell.
35+
/// Rewrite a vp-managed `.cmd` invocation to go through PowerShell.
3036
///
31-
/// Returns `Some((powershell_host, prefix_args))` when the rewrite applies,
32-
/// where `prefix_args` is `["-NoProfile", "-NoLogo", "-ExecutionPolicy",
33-
/// "Bypass", "-File", <abs ps1 path>]`. Caller prepends `prefix_args` to the
34-
/// user args and spawns `powershell_host`.
37+
/// Returns `Some((powershell_host, prefix_args))` when the rewrite applies.
38+
/// `prefix_args` is `["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass",
39+
/// "-File", <abs ps1 path>]`; callers prepend it to the user args and spawn
40+
/// `powershell_host`.
3541
///
3642
/// Returns `None` when:
3743
/// - not on Windows,
3844
/// - no PowerShell host (`pwsh.exe` or `powershell.exe`) is on PATH,
45+
/// - `$VP_HOME` could not be resolved,
46+
/// - the resolved path is **outside** `$VP_HOME`,
3947
/// - the resolved path is not a `.cmd` (case-insensitive),
4048
/// - the `.cmd` has no sibling `.ps1`.
4149
#[must_use]
4250
pub fn rewrite_cmd_to_powershell(
4351
resolved: &AbsolutePath,
4452
) -> Option<(AbsolutePathBuf, Vec<OsString>)> {
53+
let vp_home = vp_home()?;
4554
let host = powershell_host()?;
46-
rewrite_with_host(resolved, host)
55+
rewrite_in_scope(resolved, vp_home, host)
56+
}
57+
58+
/// Cached `$VP_HOME` (`~/.vite-plus` by default; overridable via env var).
59+
/// `None` only if `vite_shared::get_vp_home()` failed to resolve a home —
60+
/// in that case we conservatively skip the rewrite rather than retarget
61+
/// arbitrary PATH commands.
62+
fn vp_home() -> Option<&'static AbsolutePathBuf> {
63+
use std::sync::LazyLock;
64+
65+
static VP_HOME: LazyLock<Option<AbsolutePathBuf>> =
66+
LazyLock::new(|| vite_shared::get_vp_home().ok());
67+
VP_HOME.as_ref()
4768
}
4869

4970
/// Pure rewrite logic. Factored out so tests can drive it on any platform
50-
/// without depending on a real `powershell.exe`.
51-
fn rewrite_with_host(
71+
/// without depending on a real `powershell.exe` or a real `$VP_HOME`.
72+
fn rewrite_in_scope(
5273
resolved: &AbsolutePath,
74+
vp_home: &AbsolutePath,
5375
host: &Arc<AbsolutePath>,
5476
) -> Option<(AbsolutePathBuf, Vec<OsString>)> {
77+
if !resolved.as_path().starts_with(vp_home.as_path()) {
78+
return None;
79+
}
5580
let ps1 = find_ps1_sibling(resolved)?;
5681

5782
tracing::debug!(
@@ -86,36 +111,70 @@ mod tests {
86111
}
87112

88113
#[test]
89-
fn rewrites_cmd_to_powershell_with_sibling_ps1() {
114+
fn rewrites_cmd_inside_vp_home_to_powershell() {
90115
let dir = tempdir().unwrap();
91-
let root = abs(dir.path().canonicalize().unwrap());
92-
fs::write(root.as_path().join("npm.cmd"), "").unwrap();
93-
fs::write(root.as_path().join("npm.ps1"), "").unwrap();
116+
let vp_home = abs(dir.path().canonicalize().unwrap());
117+
// Mimic the real layout: $VP_HOME/js_runtime/node/<ver>/npm.cmd.
118+
let bin_dir = vp_home.as_path().join("js_runtime").join("node").join("24.0.0");
119+
fs::create_dir_all(&bin_dir).unwrap();
120+
fs::write(bin_dir.join("npm.cmd"), "").unwrap();
121+
fs::write(bin_dir.join("npm.ps1"), "").unwrap();
94122

95-
let host = host_arc(&root);
96-
let resolved = abs(root.as_path().join("npm.cmd"));
123+
let host = host_arc(&vp_home);
124+
let resolved = abs(bin_dir.join("npm.cmd"));
97125

98-
let (program, prefix_args) = rewrite_with_host(&resolved, &host).expect("should rewrite");
126+
let (program, prefix_args) =
127+
rewrite_in_scope(&resolved, &vp_home, &host).expect("should rewrite");
99128

100129
assert_eq!(program.as_path(), host.as_path());
101130
let as_strs: Vec<&str> = prefix_args.iter().filter_map(|a| a.to_str()).collect();
102-
let ps1_path = root.as_path().join("npm.ps1");
103-
let ps1_str = ps1_path.to_str().unwrap();
131+
let ps1_str = bin_dir.join("npm.ps1");
132+
let ps1_str = ps1_str.to_str().unwrap();
104133
assert_eq!(
105134
as_strs,
106135
vec!["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File", ps1_str]
107136
);
108137
}
109138

139+
/// Regression for the Codex review: the rewrite must NOT retarget
140+
/// `.cmd` files that live outside `$VP_HOME` even when a sibling
141+
/// `.ps1` exists. Otherwise unrelated PATH commands (system tools,
142+
/// globally installed npm wrappers, third-party CLIs whose `.cmd`
143+
/// and `.ps1` wrappers diverge) would silently switch to PowerShell
144+
/// with `-ExecutionPolicy Bypass`.
110145
#[test]
111-
fn returns_none_when_no_ps1_sibling() {
146+
fn returns_none_for_cmd_outside_vp_home() {
112147
let dir = tempdir().unwrap();
113148
let root = abs(dir.path().canonicalize().unwrap());
114-
fs::write(root.as_path().join("npm.cmd"), "").unwrap();
149+
let vp_home_path = root.as_path().join("vite-plus");
150+
fs::create_dir_all(&vp_home_path).unwrap();
151+
let vp_home = abs(vp_home_path);
152+
153+
// A `.cmd`+`.ps1` pair *outside* vp_home — e.g. a global install at
154+
// `%AppData%\npm\node_modules\.bin\foo.cmd` or any third-party tool.
155+
let outside_bin = root.as_path().join("global").join("bin");
156+
fs::create_dir_all(&outside_bin).unwrap();
157+
fs::write(outside_bin.join("foo.cmd"), "").unwrap();
158+
fs::write(outside_bin.join("foo.ps1"), "").unwrap();
115159

116160
let host = host_arc(&root);
117-
let resolved = abs(root.as_path().join("npm.cmd"));
161+
let resolved = abs(outside_bin.join("foo.cmd"));
162+
163+
assert!(
164+
rewrite_in_scope(&resolved, &vp_home, &host).is_none(),
165+
"rewrite must stay hands-off for .cmd outside $VP_HOME"
166+
);
167+
}
168+
169+
#[test]
170+
fn returns_none_when_no_ps1_sibling() {
171+
let dir = tempdir().unwrap();
172+
let vp_home = abs(dir.path().canonicalize().unwrap());
173+
fs::write(vp_home.as_path().join("npm.cmd"), "").unwrap();
174+
175+
let host = host_arc(&vp_home);
176+
let resolved = abs(vp_home.as_path().join("npm.cmd"));
118177

119-
assert!(rewrite_with_host(&resolved, &host).is_none());
178+
assert!(rewrite_in_scope(&resolved, &vp_home, &host).is_none());
120179
}
121180
}

0 commit comments

Comments
 (0)