Skip to content

Commit ae3497f

Browse files
fix(npm): use native Windows bsdtar, fail loud on empty extraction (#134)
Real failure chain on Windows was two-layered: 1. GNU tar (resolved from PATH under Git Bash, npm's postinstall shell) parses `C:\...` as rsh host:path, prints "Cannot connect to C: resolve failed", and exits 0 with nothing extracted. The try/catch PowerShell fallback never fired because tar didn't throw. 2. Even when the fallback did run, Windows Defender held a scan-lock on the freshly-downloaded .exe-containing zip; Expand-Archive kept failing with "The process cannot access the file ... because it is being used by another process". The existing retry loop silently swallowed all 10 retries and returned, so the caller never knew extraction failed. Fix: - Call the native Windows bsdtar directly at %SystemRoot%\System32\tar.exe (Windows 10+). bsdtar handles both zip format and `C:\` paths natively and isn't blocked by Defender the way Expand-Archive is. - Keep PowerShell Expand-Archive as a fallback for older Windows, with the retry loop bumped from 10 → 20 attempts and — critically — a `throw $lastErr` so exhausted retries surface instead of silently returning. - Always verify `tmpDir` is non-empty after extraction; if a tool exits 0 without writing anything, treat that as failure and try the next one. - Wait for the download stream's `close` event, not just `finish`, so the fd is truly released before any extractor runs. Tests rewritten to cover: first-extractor success, fallback on throw, fallback on silent-empty success, throw-on-exhaustion for both failure modes, PS retry-loop shape, and path propagation. Closes #133
1 parent e087c31 commit ae3497f

2 files changed

Lines changed: 150 additions & 112 deletions

File tree

npm/install.js

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,25 +39,59 @@ function download(url, dest, cb) {
3939
return fail(`HTTP ${res.statusCode} downloading ${url}`);
4040
}
4141
res.pipe(file);
42-
file.on("finish", () => file.close(cb));
42+
// Wait for 'close' (not just 'finish'): on Windows the fd is not actually
43+
// released until close completes, and subsequent extractors (PowerShell
44+
// Expand-Archive in particular) will fail with a file-in-use error.
45+
file.on("finish", () => file.close());
46+
file.on("close", () => cb());
4347
}).on("error", (err) => fail(err.message));
4448
}
4549

4650
// extractZip extracts a .zip archive into tmpDir.
47-
// Tries native tar first (Windows 10+); falls back to PowerShell Expand-Archive
48-
// with a retry loop to handle transient Antivirus file locks.
51+
//
52+
// On Windows, PATH under Git Bash (npm's postinstall shell) resolves `tar`
53+
// to GNU tar, which parses `C:\...` as `host:path` (rsh syntax), prints
54+
// "Cannot connect to C: resolve failed", and exits 0 with nothing extracted.
55+
// We explicitly call the native Windows bsdtar at %SystemRoot%\System32\tar.exe
56+
// (present on Windows 10+), which handles both Windows paths and the zip
57+
// format. On older Windows we fall back to PowerShell Expand-Archive with
58+
// retries for Defender/indexer file locks.
59+
//
60+
// After extraction we always verify tmpDir is non-empty — some extractors
61+
// (notably GNU tar in the failure mode above) exit 0 without writing anything.
4962
// Accepts an optional execFn for testing (defaults to execSync).
5063
function extractZip(archive, tmpDir, execFn) {
5164
const exec = execFn || execSync;
52-
try {
53-
exec(`tar -xf "${archive}" -C "${tmpDir}"`);
54-
} catch {
55-
const psCommand =
56-
`$RetryCount = 0; while ($RetryCount -lt 10) { try { Expand-Archive` +
57-
` -Force -Path '${archive}' -DestinationPath '${tmpDir}'; break }` +
58-
` catch { Start-Sleep -Seconds 1; $RetryCount++ } }`;
59-
exec(`powershell -NoProfile -Command "${psCommand}"`);
65+
const attempts = [];
66+
67+
const bsdtar = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe");
68+
if (process.platform === "win32" && fs.existsSync(bsdtar)) {
69+
attempts.push({ cmd: `"${bsdtar}" -xf "${archive}" -C "${tmpDir}"`, label: "bsdtar" });
70+
}
71+
// PowerShell fallback. Retries handle transient locks from Windows Defender
72+
// / Search Indexer on the freshly-written zip. Throws if all retries fail.
73+
const psCommand =
74+
`$ErrorActionPreference='Stop'; ` +
75+
`$lastErr = $null; ` +
76+
`for ($i=0; $i -lt 20; $i++) { ` +
77+
` try { Expand-Archive -Force -Path '${archive}' -DestinationPath '${tmpDir}'; exit 0 } ` +
78+
` catch { $lastErr = $_; Start-Sleep -Seconds 1 } ` +
79+
`}; ` +
80+
`throw $lastErr`;
81+
attempts.push({ cmd: `powershell -NoProfile -Command "${psCommand}"`, label: "powershell" });
82+
83+
let lastErr = null;
84+
for (const { cmd } of attempts) {
85+
try {
86+
exec(cmd);
87+
let entries = [];
88+
try { entries = fs.readdirSync(tmpDir); } catch { /* missing dir => failure */ }
89+
if (entries.length > 0) return;
90+
} catch (e) {
91+
lastErr = e;
92+
}
6093
}
94+
throw lastErr || new Error(`[supermodel] extraction produced no files in ${tmpDir}`);
6195
}
6296

6397
if (require.main === module) {

npm/install.test.js

Lines changed: 105 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -11,123 +11,127 @@ const path = require("path");
1111
const { execSync } = require("child_process");
1212
const { extractZip } = require("./install");
1313

14-
// createTestZip builds a real .zip containing a single file named "supermodel.exe"
15-
// using the system zip/tar command. Skips on platforms where neither is available.
16-
function createTestZip(t) {
17-
const src = fs.mkdtempSync(path.join(os.tmpdir(), "supermodel-test-src-"));
18-
const binary = path.join(src, "supermodel.exe");
19-
fs.writeFileSync(binary, "fake binary");
14+
const isWindows = process.platform === "win32";
15+
const isTarCmd = (c) => /tar(\.exe)?["'\s]/.test(c.split(" ")[0]);
16+
const isPsCmd = (c) => c.includes("powershell");
2017

21-
const archive = path.join(os.tmpdir(), `supermodel-test-${process.pid}.zip`);
18+
// Make a fake extraction succeed by dropping a file into tmpDir so the
19+
// post-extract verification (readdirSync(tmpDir).length > 0) passes.
20+
const fakeExtract = (tmpDir) => () => {
21+
fs.writeFileSync(path.join(tmpDir, "supermodel.exe"), "fake");
22+
};
23+
24+
test("extractZip succeeds when the first extractor produces files", () => {
25+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "smt-ok-"));
2226
try {
23-
// Use system zip or tar to build the archive.
24-
try {
25-
execSync(`zip -j "${archive}" "${binary}"`, { stdio: "pipe" });
26-
} catch {
27-
execSync(`tar -cf "${archive}" --format=zip -C "${src}" supermodel.exe`, { stdio: "pipe" });
28-
}
29-
} catch {
30-
fs.rmSync(src, { recursive: true, force: true });
31-
return null; // zip tooling not available — caller should skip
27+
const commands = [];
28+
extractZip("/fake/archive.zip", tmpDir, (cmd) => {
29+
commands.push(cmd);
30+
fakeExtract(tmpDir)();
31+
});
32+
assert.equal(commands.length, 1, "stops at first successful extractor");
33+
} finally {
34+
fs.rmSync(tmpDir, { recursive: true, force: true });
3235
}
33-
fs.rmSync(src, { recursive: true, force: true });
34-
return archive;
35-
}
36+
});
3637

37-
test("extractZip extracts via tar when tar succeeds", () => {
38-
const archive = createTestZip();
39-
if (!archive) {
40-
// Skip gracefully if zip tooling unavailable (e.g. minimal CI image).
41-
console.log(" skipped: zip tooling not available");
42-
return;
43-
}
38+
test("extractZip falls back when first extractor throws", { skip: !isWindows }, () => {
39+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "smt-throw-"));
4440
try {
45-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "supermodel-test-out-"));
46-
try {
47-
let called = null;
48-
extractZip(archive, tmpDir, (cmd) => {
49-
called = cmd;
50-
// Only simulate tar; let the actual extraction happen via real execSync
51-
// if this is the tar command.
52-
if (cmd.startsWith("tar")) {
53-
execSync(cmd, { stdio: "pipe" });
54-
} else {
55-
throw new Error("should not reach PowerShell");
56-
}
57-
});
58-
assert.ok(called.startsWith("tar"), "should have called tar first");
59-
const extracted = fs.readdirSync(tmpDir);
60-
assert.ok(extracted.length > 0, "tmpDir should contain extracted files");
61-
} finally {
62-
fs.rmSync(tmpDir, { recursive: true, force: true });
63-
}
41+
const commands = [];
42+
extractZip("/fake/archive.zip", tmpDir, (cmd) => {
43+
commands.push(cmd);
44+
if (isTarCmd(cmd)) throw new Error("tar unavailable");
45+
fakeExtract(tmpDir)();
46+
});
47+
assert.equal(commands.length, 2, "tries tar then PowerShell");
48+
assert.ok(isTarCmd(commands[0]), "first attempt is tar");
49+
assert.ok(isPsCmd(commands[1]), "second attempt is PowerShell");
6450
} finally {
65-
fs.unlinkSync(archive);
51+
fs.rmSync(tmpDir, { recursive: true, force: true });
6652
}
6753
});
6854

69-
test("extractZip falls back to PowerShell when tar fails", () => {
70-
const commands = [];
71-
// Simulate tar failing; PowerShell "succeeds" (no-op).
72-
extractZip("/fake/archive.zip", "/fake/tmpdir", (cmd) => {
73-
commands.push(cmd);
74-
if (cmd.startsWith("tar")) throw new Error("tar not available");
75-
// PowerShell call — just record it, don't execute.
76-
});
77-
78-
assert.equal(commands.length, 2, "should have attempted tar then PowerShell");
79-
assert.ok(commands[0].startsWith("tar"), "first call should be tar");
80-
assert.ok(commands[1].includes("powershell"), "second call should be PowerShell");
81-
assert.ok(commands[1].includes("Expand-Archive"), "PowerShell command should use Expand-Archive");
55+
test("extractZip falls back when first extractor exits 0 but writes nothing", { skip: !isWindows }, () => {
56+
// Reproduces the GNU-tar-in-Git-Bash bug: tar prints "Cannot connect to C:"
57+
// but still exits 0 without extracting anything.
58+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "smt-silent-"));
59+
try {
60+
const commands = [];
61+
extractZip("/fake/archive.zip", tmpDir, (cmd) => {
62+
commands.push(cmd);
63+
if (isTarCmd(cmd)) return; // silent no-op "success"
64+
fakeExtract(tmpDir)();
65+
});
66+
assert.equal(commands.length, 2, "falls back when tar produced no files");
67+
assert.ok(isPsCmd(commands[1]), "fallback is PowerShell Expand-Archive");
68+
} finally {
69+
fs.rmSync(tmpDir, { recursive: true, force: true });
70+
}
8271
});
8372

84-
test("extractZip PowerShell fallback includes retry loop", () => {
85-
const commands = [];
86-
extractZip("/fake/archive.zip", "/fake/tmpdir", (cmd) => {
87-
commands.push(cmd);
88-
if (cmd.startsWith("tar")) throw new Error("tar not available");
89-
});
90-
91-
const psCmd = commands.find((c) => c.includes("powershell"));
92-
assert.ok(psCmd, "PowerShell command should be present");
93-
assert.ok(psCmd.includes("$RetryCount"), "should include retry counter");
94-
assert.ok(psCmd.includes("Start-Sleep"), "should include sleep between retries");
95-
assert.ok(psCmd.includes("-lt 10"), "should retry up to 10 times");
73+
test("extractZip throws when every extractor fails", () => {
74+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "smt-fail-"));
75+
try {
76+
assert.throws(() => {
77+
extractZip("/fake/archive.zip", tmpDir, () => {
78+
throw new Error("boom");
79+
});
80+
}, /boom/);
81+
} finally {
82+
fs.rmSync(tmpDir, { recursive: true, force: true });
83+
}
9684
});
9785

98-
test("extractZip uses tar when both succeed — tar wins", () => {
99-
const commands = [];
100-
extractZip("/fake/archive.zip", "/fake/tmpdir", (cmd) => {
101-
commands.push(cmd);
102-
// Both would succeed; tar is tried first and doesn't throw.
103-
});
104-
105-
assert.equal(commands.length, 1, "should only call tar when it succeeds");
106-
assert.ok(commands[0].startsWith("tar"), "the single call should be tar");
86+
test("extractZip throws when every extractor silently produces nothing", () => {
87+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "smt-empty-"));
88+
try {
89+
assert.throws(() => {
90+
extractZip("/fake/archive.zip", tmpDir, () => {
91+
// no-op: exit 0, no files — the exact silent-failure mode we must catch
92+
});
93+
});
94+
} finally {
95+
fs.rmSync(tmpDir, { recursive: true, force: true });
96+
}
10797
});
10898

109-
test("extractZip passes archive and tmpDir paths into tar command", () => {
110-
const archive = "/tmp/test.zip";
111-
const tmpDir = "/tmp/extract-dir";
112-
let tarCmd = null;
113-
extractZip(archive, tmpDir, (cmd) => {
114-
tarCmd = cmd;
115-
});
116-
117-
assert.ok(tarCmd.includes(archive), "tar command should include archive path");
118-
assert.ok(tarCmd.includes(tmpDir), "tar command should include tmpDir path");
99+
test("extractZip PowerShell fallback uses Expand-Archive with a retry loop", () => {
100+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "smt-ps-"));
101+
try {
102+
const commands = [];
103+
try {
104+
extractZip("/fake/archive.zip", tmpDir, (cmd) => {
105+
commands.push(cmd);
106+
throw new Error("all fail");
107+
});
108+
} catch {}
109+
const psCmd = commands.find(isPsCmd);
110+
assert.ok(psCmd, "PowerShell command should be attempted");
111+
assert.ok(psCmd.includes("Expand-Archive"), "uses Expand-Archive");
112+
assert.ok(psCmd.includes("Start-Sleep"), "sleeps between retries");
113+
assert.match(psCmd, /-lt\s+\d+/, "has a retry counter");
114+
} finally {
115+
fs.rmSync(tmpDir, { recursive: true, force: true });
116+
}
119117
});
120118

121-
test("extractZip passes archive and tmpDir paths into PowerShell fallback", () => {
119+
test("extractZip passes archive and tmpDir paths into every command", () => {
122120
const archive = "/tmp/test.zip";
123-
const tmpDir = "/tmp/extract-dir";
124-
const commands = [];
125-
extractZip(archive, tmpDir, (cmd) => {
126-
commands.push(cmd);
127-
if (cmd.startsWith("tar")) throw new Error("tar failed");
128-
});
129-
130-
const psCmd = commands[1];
131-
assert.ok(psCmd.includes(archive), "PowerShell command should include archive path");
132-
assert.ok(psCmd.includes(tmpDir), "PowerShell command should include tmpDir path");
121+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "smt-paths-"));
122+
try {
123+
const commands = [];
124+
try {
125+
extractZip(archive, tmpDir, (cmd) => {
126+
commands.push(cmd);
127+
throw new Error("fail all");
128+
});
129+
} catch {}
130+
for (const cmd of commands) {
131+
assert.ok(cmd.includes(archive), `archive path present: ${cmd}`);
132+
assert.ok(cmd.includes(tmpDir), `tmpDir path present: ${cmd}`);
133+
}
134+
} finally {
135+
fs.rmSync(tmpDir, { recursive: true, force: true });
136+
}
133137
});

0 commit comments

Comments
 (0)