Skip to content

Commit d24cc30

Browse files
committed
fix: validate native bindings and preserve installer overrides
1 parent 0fb6836 commit d24cc30

5 files changed

Lines changed: 314 additions & 56 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,45 @@ jobs:
156156
fi
157157
exit "$failed"
158158
159+
test-reuse-installation-integrity:
160+
strategy:
161+
fail-fast: false
162+
matrix:
163+
os: [ubuntu-latest, macos-latest, windows-latest]
164+
runs-on: ${{ matrix.os }}
165+
timeout-minutes: 10
166+
env:
167+
VP_PR_VERSION: ""
168+
steps:
169+
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
170+
171+
- name: Prepare an isolated global installation and project
172+
run: node test/reuse-installation-integrity.mjs prepare
173+
174+
- name: Install Vite+
175+
uses: ./
176+
with:
177+
version: "0.3.3"
178+
run-install: false
179+
cache: false
180+
181+
- name: Remove the active native binding and reproduce the CLI failure
182+
run: node test/reuse-installation-integrity.mjs remove-binding
183+
184+
- name: Repair the incomplete installation
185+
uses: ./
186+
with:
187+
version: "0.3.3"
188+
run-install: false
189+
cache: false
190+
191+
- name: Check that JavaScript CLI commands work again
192+
run: node test/reuse-installation-integrity.mjs verify-repair
193+
194+
- name: Check combined and per-manager installer overrides on an existing installation
195+
if: runner.os == 'Linux'
196+
run: node test/reuse-installation-integrity.mjs manager-overrides
197+
159198
test-reuse-restored-prepare:
160199
runs-on: ubuntu-latest
161200
timeout-minutes: 10

‎dist/index.mjs‎

Lines changed: 49 additions & 46 deletions
Large diffs are not rendered by default.

‎src/reuse-viteplus.test.ts‎

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ let data: string;
1414
let bin: string;
1515
let config: string;
1616
let versionDir: string;
17+
let nativeFile: string;
1718
let version: string;
1819
let env: NodeJS.ProcessEnv;
1920
let platform: NodeJS.Platform;
@@ -63,6 +64,9 @@ function createInstallation({
6364
}),
6465
);
6566
write(join(packageDir, "dist", "bin.js"));
67+
write(join(packageDir, "binding", "index.cjs"));
68+
nativeFile = join(packageDir, "..", "@voidzero-dev", "vite-plus-native", "vite-plus.node");
69+
write(nativeFile);
6670
write(
6771
join(packageDir, "..", "fixture-dep", "package.json"),
6872
'{"name":"fixture-dep","version":"1.0.0"}',
@@ -95,11 +99,12 @@ beforeEach(() => {
9599
env = { VP_HOME: data, HOME: join(root, "home"), USERPROFILE: join(root, "home") };
96100
platform = process.platform;
97101
version = "0.3.0";
98-
vi.mocked(execFileSync).mockImplementation((_file, args) =>
99-
(args as string[]).includes("--version")
102+
vi.mocked(execFileSync).mockImplementation((file, args) => {
103+
if (file === process.execPath) return JSON.stringify([nativeFile]);
104+
return (args as string[]).includes("--version")
100105
? `vp v${version}\nLocal vite-plus v9.9.9\n`
101-
: dirsOutput(),
102-
);
106+
: dirsOutput();
107+
});
103108
});
104109

105110
afterEach(() => {
@@ -225,6 +230,7 @@ describe("findReusableVitePlus", () => {
225230
"package.json",
226231
"node_modules/vite-plus/package.json",
227232
"node_modules/vite-plus/dist/bin.js",
233+
"node_modules/vite-plus/binding/index.cjs",
228234
"node_modules/.pnpm/vite-plus@0.3.0/node_modules/fixture-dep/package.json",
229235
])("falls back when %s is missing", (file) => {
230236
createInstallation();
@@ -245,6 +251,59 @@ describe("findReusableVitePlus", () => {
245251
expect(reuse()).toBeUndefined();
246252
});
247253

254+
it.each(["package", "file"])(
255+
"falls back when the loaded native binding %s is missing",
256+
(target) => {
257+
createInstallation();
258+
rmSync(target === "package" ? dirname(nativeFile) : nativeFile, { recursive: true });
259+
expect(reuse()).toBeUndefined();
260+
},
261+
);
262+
263+
it("probes the installed binding loader with the action's Node runtime", () => {
264+
createInstallation();
265+
expect(reuse()).toBe(bin);
266+
expect(execFileSync).toHaveBeenCalledWith(
267+
process.execPath,
268+
["--input-type=commonjs", "--eval", expect.any(String), expect.stringContaining("index.cjs")],
269+
expect.objectContaining({
270+
env: { ...env, NAPI_RS_ENFORCE_VERSION_CHECK: "1" },
271+
cwd: tmpdir(),
272+
timeout: 5000,
273+
}),
274+
);
275+
});
276+
277+
it("falls back when the native loader fails", () => {
278+
createInstallation();
279+
vi.mocked(execFileSync)
280+
.mockReturnValueOnce(dirsOutput())
281+
.mockReturnValueOnce(`vp v${version}`)
282+
.mockImplementationOnce(() => {
283+
throw new Error("Cannot find native binding");
284+
});
285+
expect(reuse()).toBeUndefined();
286+
});
287+
288+
it.each(["[]", "null", "invalid JSON"])(
289+
"rejects a native probe without binding files: %s",
290+
(output) => {
291+
createInstallation();
292+
vi.mocked(execFileSync)
293+
.mockReturnValueOnce(dirsOutput())
294+
.mockReturnValueOnce(`vp v${version}`)
295+
.mockReturnValueOnce(output);
296+
expect(reuse()).toBeUndefined();
297+
},
298+
);
299+
300+
it("does not accept a native binding from outside the active installation", () => {
301+
createInstallation();
302+
nativeFile = join(root, "other-installation", "vite-plus.node");
303+
write(nativeFile);
304+
expect(reuse()).toBeUndefined();
305+
});
306+
248307
it.each(["{", '{"name":"vp-global","version":"0.3.1","dependencies":{"vite-plus":"0.3.0"}}'])(
249308
"rejects invalid or mismatched wrapper metadata",
250309
(content) => {
@@ -323,11 +382,26 @@ describe("findReusableVitePlus", () => {
323382
expect(reuse()).toBe(bin);
324383
});
325384

326-
it.each(["no", "invalid"])("does not bypass VP_NODE_MANAGER=%s", (value) => {
327-
createInstallation();
328-
env.VP_NODE_MANAGER = value;
329-
expect(reuse()).toBeUndefined();
330-
expect(execFileSync).not.toHaveBeenCalled();
385+
describe.each([
386+
"VP_NODE_MANAGER",
387+
"VP_PM_MANAGER",
388+
"VP_NPM_MANAGER",
389+
"VP_PNPM_MANAGER",
390+
"VP_YARN_MANAGER",
391+
"VP_BUN_MANAGER",
392+
])("%s installer override", (key) => {
393+
it.each(["no", "invalid"])("leaves %s to the installer", (value) => {
394+
createInstallation();
395+
env[key] = value;
396+
expect(reuse()).toBeUndefined();
397+
expect(execFileSync).not.toHaveBeenCalled();
398+
});
399+
400+
it.each(["yes", ""])("reuses managed installations for %s", (value) => {
401+
createInstallation();
402+
env[key] = value;
403+
expect(reuse()).toBe(bin);
404+
});
331405
});
332406

333407
it("falls back when a required shim is missing", () => {

‎src/reuse-viteplus.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ import type { VitePlusBinDirs, VitePlusDirs } from "./ci/vp-dirs.js";
1414
const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
1515
const COMMON_SHIMS = ["vp", "node", "npm", "npx", "vpx", "vpr"];
1616
const PACKAGE_MANAGER_SHIMS = ["pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx"];
17+
const MANAGER_OVERRIDES = [
18+
"VP_NODE_MANAGER",
19+
"VP_PM_MANAGER",
20+
"VP_NPM_MANAGER",
21+
"VP_PNPM_MANAGER",
22+
"VP_YARN_MANAGER",
23+
"VP_BUN_MANAGER",
24+
];
1725

1826
interface PackageJson {
1927
name?: string;
@@ -42,7 +50,7 @@ export function findReusableVitePlus(
4250
env.VP_LOCAL_TGZ ||
4351
env.VP_LOCAL_BINARY ||
4452
env.VP_SKIP_DEPS_INSTALL ||
45-
(env.VP_NODE_MANAGER && env.VP_NODE_MANAGER !== "yes") ||
53+
MANAGER_OVERRIDES.some((key) => env[key] && env[key] !== "yes") ||
4654
!hasValidDirOverrides(env)
4755
) {
4856
return undefined;
@@ -81,6 +89,7 @@ export function findReusableVitePlus(
8189
if (parseInstalledVpVersion(versionOutput) !== version) continue;
8290
if (!hasManagedEnvironment(dirs.config, platform)) continue;
8391
if (!hasValidShims(dirs, binary, platform, layout, versionOutput)) continue;
92+
if (!hasNativeBinding(versionDir, options)) continue;
8493

8594
return getVitePlusBinDirs(dirs);
8695
} catch (error) {
@@ -148,6 +157,38 @@ function isInside(parent: string, child: string): boolean {
148157
return path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path);
149158
}
150159

160+
function hasNativeBinding(
161+
versionDir: string,
162+
options: ExecFileSyncOptionsWithStringEncoding,
163+
): boolean {
164+
const loader = realpathSync(
165+
join(versionDir, "node_modules", "vite-plus", "binding", "index.cjs"),
166+
);
167+
if (!isInside(versionDir, loader) || !statSync(loader).isFile()) return false;
168+
169+
// Let the installed loader select the platform, architecture, and libc.
170+
// A child process isolates native load failures from the action runtime.
171+
const script = `
172+
require(process.argv[1]);
173+
process.stdout.write(JSON.stringify(Object.keys(require.cache).filter(file => file.endsWith('.node'))));
174+
`;
175+
const files: unknown = JSON.parse(
176+
execFileSync(process.execPath, ["--input-type=commonjs", "--eval", script, loader], {
177+
...options,
178+
env: { ...options.env, NAPI_RS_ENFORCE_VERSION_CHECK: "1" },
179+
}),
180+
);
181+
// A binding from another installation must not hide a damaged active one.
182+
return (
183+
Array.isArray(files) &&
184+
files.length > 0 &&
185+
files.every(
186+
(file) =>
187+
typeof file === "string" && isAbsolute(file) && isInside(versionDir, realpathSync(file)),
188+
)
189+
);
190+
}
191+
151192
function hasManagedEnvironment(configDir: string, platform: NodeJS.Platform): boolean {
152193
const configFile = join(configDir, "config.json");
153194
const config = existsSync(configFile)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import assert from "node:assert/strict";
2+
import { execFileSync, spawnSync } from "node:child_process";
3+
import { appendFileSync, mkdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
4+
import { dirname, join } from "node:path";
5+
import { fileURLToPath } from "node:url";
6+
7+
const root = join(process.env.RUNNER_TEMP, "setup-vp-reuse-integrity");
8+
const project = join(root, "project");
9+
const home = join(root, "installation");
10+
const vp = join(home, "bin", process.platform === "win32" ? "vp.exe" : "vp");
11+
const bundle = fileURLToPath(new URL("../dist/index.mjs", import.meta.url));
12+
const managers = ["npm", "pnpm", "yarn", "bun"];
13+
const overrides = ["VP_PM_MANAGER", ...managers.map((name) => `VP_${name.toUpperCase()}_MANAGER`)];
14+
15+
function runVp(args) {
16+
return execFileSync(vp, args, { cwd: project, encoding: "utf8" });
17+
}
18+
19+
switch (process.argv[2]) {
20+
case "prepare": {
21+
mkdirSync(project, { recursive: true });
22+
writeFileSync(join(project, "package.json"), '{"private":true}');
23+
writeFileSync(join(project, "review.js"), "console.log('binding works');\n");
24+
appendFileSync(process.env.GITHUB_ENV, `VP_HOME=${home}\n`);
25+
break;
26+
}
27+
28+
case "remove-binding": {
29+
runVp(["lint", "review.js"]);
30+
const packageDir = realpathSync(join(home, "current", "node_modules", "vite-plus"));
31+
// Load in a child so Windows releases the native library before we move it.
32+
const files = JSON.parse(
33+
execFileSync(
34+
process.execPath,
35+
[
36+
"--input-type=commonjs",
37+
"--eval",
38+
"require(process.argv[1]); console.log(JSON.stringify(Object.keys(require.cache).filter(file => file.endsWith('.node'))));",
39+
join(packageDir, "binding", "index.cjs"),
40+
],
41+
{ cwd: project, encoding: "utf8" },
42+
),
43+
);
44+
const nativeFile = files.find((file) => file.includes("vite-plus"));
45+
assert.ok(nativeFile, "The global CLI must load its native binding");
46+
// Moving a single pnpm link is insufficient: another link can still resolve it.
47+
renameSync(dirname(realpathSync(nativeFile)), join(root, "removed-native-package"));
48+
runVp(["--version"]);
49+
const broken = spawnSync(vp, ["lint", "review.js"], { cwd: project, encoding: "utf8" });
50+
assert.notEqual(broken.status, 0);
51+
assert.match(broken.stderr, /Cannot find native binding/);
52+
console.log("Native version probe succeeds, but the JavaScript CLI has a missing binding.");
53+
break;
54+
}
55+
56+
case "verify-repair": {
57+
runVp(["lint", "review.js"]);
58+
console.log("The action repaired the missing native binding.");
59+
break;
60+
}
61+
62+
case "manager-overrides": {
63+
for (const override of overrides) {
64+
runVp(["env", "on"]);
65+
const env = {
66+
...process.env,
67+
INPUT_VERSION: "0.3.3",
68+
"INPUT_RUN-INSTALL": "false",
69+
INPUT_CACHE: "false",
70+
"INPUT_CACHE-SAVE": "false",
71+
INPUT_SFW: "false",
72+
};
73+
for (const key of overrides) delete env[key];
74+
env[override] = "no";
75+
const output = execFileSync(process.execPath, [bundle], {
76+
cwd: project,
77+
env,
78+
encoding: "utf8",
79+
timeout: 180_000,
80+
});
81+
assert.doesNotMatch(output, /Reusing Vite\+/);
82+
for (const manager of managers) {
83+
const { package_manager: current } = JSON.parse(
84+
runVp(["env", "current", manager, "--json"]),
85+
);
86+
const disabled =
87+
override === "VP_PM_MANAGER" || override === `VP_${manager.toUpperCase()}_MANAGER`;
88+
assert.equal(
89+
current.mode,
90+
disabled ? "system_first" : "managed",
91+
`${override}: ${manager}`,
92+
);
93+
}
94+
console.log(`${override}=no applies the installer package-manager settings.`);
95+
}
96+
break;
97+
}
98+
99+
default:
100+
throw new Error(`Unknown integrity check: ${process.argv[2]}`);
101+
}

0 commit comments

Comments
 (0)