mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
On Node v24.16.0 / v26.1.0+, `impeccable install` printed "Downloading impeccable skills...", exited 0, and installed nothing. A Node streams regression (nodejs/node#63487) made pause()/resume() no-ops on destroyed streams, stalling extract-zip's yauzl/fd-slicer read stack partway through; its promise never settled and the process exited clean with no error. Swap extract-zip for fflate across both extraction call sites (downloadAndExtractBundle, copyOrExtractLocalBundle) via a new extractZip helper. fflate decompresses from an in-memory buffer and never touches the fs stream path, so it is immune on every Node version. It is pure JS with zero dependencies, so the Windows fix from #198 (no `unzip` binary) holds. Unlike extract-zip, fflate is actively maintained. Because extractZip writes entries itself, it guards against zip-slip (`../` entries escaping the target dir). Tests add a many-file regression guard (fails on partial extraction) and a zip-slip rejection test. Verified end-to-end: the real 1,194-file universal bundle extracts and installs completely. Fixes #250. Co-authored-by: Claude <noreply@anthropic.com>
174 lines
7.8 KiB
JavaScript
174 lines
7.8 KiB
JavaScript
/**
|
|
* Unit tests for the release-bundle zip writer (scripts/lib/zip.js).
|
|
* Run: node --test tests/zip.test.mjs
|
|
*
|
|
* Regression guard for the silent-broken-bundle outage: archiver v8's ESM
|
|
* change made createProviderZip fail without throwing, so the build shipped a
|
|
* 0-byte universal.zip and every `npx impeccable install` failed with
|
|
* "End-of-central-directory signature not found". Nothing covered the zip
|
|
* writer, so the suite stayed green. These tests exercise the real writer and
|
|
* round-trip through the same unpacker the CLI uses (extractZip, backed by
|
|
* fflate). The many-file extraction test additionally guards the Node v24.16.0
|
|
* / v26.1.0+ silent partial-extraction regression (nodejs/node#63487) that
|
|
* made `npx impeccable install` exit 0 after writing only a fraction of the
|
|
* bundle.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, statSync, rmSync, readFileSync, readdirSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { createProviderZip, createAllZips } from '../scripts/lib/zip.js';
|
|
import { extractZip } from '../cli/bin/commands/skills.mjs';
|
|
|
|
function makeUniversalTree(distDir) {
|
|
const skillDir = join(distDir, 'universal', 'skills', 'impeccable');
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: impeccable\n---\nhello\n');
|
|
mkdirSync(join(distDir, 'universal', '.claude'), { recursive: true });
|
|
writeFileSync(join(distDir, 'universal', '.claude', 'settings.json'), '{}\n');
|
|
}
|
|
|
|
/**
|
|
* Build a representative multi-provider universal tree carrying far more
|
|
* entries than the Node v24.16.0 extract-zip stall point (~31 files). The
|
|
* partial-extraction regression test relies on this so a regression in the
|
|
* unpacker fails the count assertion instead of shipping silently. Returns the
|
|
* number of files written.
|
|
*/
|
|
function makeLargeUniversalTree(distDir, { providers = ['.claude', '.cursor', '.agents', '.gemini', '.github', '.kiro'], scriptCount = 8, extraSkills = ['audit', 'polish'] } = {}) {
|
|
let files = 0;
|
|
for (const provider of providers) {
|
|
// The impeccable skill ships a scripts/ dir with many files, like the real
|
|
// bundle. This is where most entries live.
|
|
const scriptsDir = join(distDir, 'universal', provider, 'skills', 'impeccable', 'scripts');
|
|
mkdirSync(scriptsDir, { recursive: true });
|
|
writeFileSync(join(distDir, 'universal', provider, 'skills', 'impeccable', 'SKILL.md'), `---\nname: impeccable\n---\nprovider ${provider}\n`);
|
|
files += 1;
|
|
for (let i = 0; i < scriptCount; i++) {
|
|
writeFileSync(join(scriptsDir, `script-${i}.mjs`), `// ${provider} script ${i}\nexport default ${i};\n`);
|
|
files += 1;
|
|
}
|
|
// Sibling skills with a SKILL.md + a reference file each.
|
|
for (const name of extraSkills) {
|
|
const refDir = join(distDir, 'universal', provider, 'skills', name, 'reference');
|
|
mkdirSync(refDir, { recursive: true });
|
|
writeFileSync(join(distDir, 'universal', provider, 'skills', name, 'SKILL.md'), `---\nname: ${name}\n---\n`);
|
|
writeFileSync(join(refDir, `${name}.md`), `# ${name} reference\n`);
|
|
files += 2;
|
|
}
|
|
// Provider root config (mirrors .claude/settings.json, .cursor/hooks.json).
|
|
writeFileSync(join(distDir, 'universal', provider, 'config.json'), '{}\n');
|
|
files += 1;
|
|
}
|
|
return files;
|
|
}
|
|
|
|
/** Count regular files under a directory tree (used to assert full extraction). */
|
|
function countFiles(dir) {
|
|
let count = 0;
|
|
const walk = (d) => {
|
|
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
if (entry.isDirectory()) walk(join(d, entry.name));
|
|
else count += 1;
|
|
}
|
|
};
|
|
walk(dir);
|
|
return count;
|
|
}
|
|
|
|
describe('release bundle zip writer', () => {
|
|
it('createAllZips produces a non-empty universal.zip that unpacks to the skill tree', async () => {
|
|
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-'));
|
|
makeUniversalTree(dist);
|
|
|
|
await createAllZips(dist);
|
|
|
|
const zipPath = join(dist, 'universal.zip');
|
|
assert.ok(existsSync(zipPath), 'universal.zip was not created');
|
|
assert.ok(statSync(zipPath).size > 0, 'universal.zip is empty (0 bytes)');
|
|
|
|
// Round-trip through the same unpacker the CLI uses (extractZip). The CLI
|
|
// downloads this exact artifact and extractZip()s it.
|
|
const out = mkdtempSync(join(tmpdir(), 'imp-unzip-'));
|
|
await extractZip(zipPath, out);
|
|
const skillMd = join(out, 'skills', 'impeccable', 'SKILL.md');
|
|
assert.ok(existsSync(skillMd), 'unpacked bundle is missing skills/impeccable/SKILL.md');
|
|
assert.match(readFileSync(skillMd, 'utf8'), /name: impeccable/);
|
|
|
|
rmSync(dist, { recursive: true, force: true });
|
|
rmSync(out, { recursive: true, force: true });
|
|
});
|
|
|
|
it('REGRESSION: extracts every entry of a many-file bundle (no silent partial extraction on Node v24.16.0+)', async () => {
|
|
// Guards nodejs/node#63487: extract-zip's yauzl/fd-slicer read stack stalls
|
|
// on Node v24.16.0 / v26.1.0+ (pause/resume on a destroyed stream became a
|
|
// no-op), so extraction stops early, its promise never settles, and --
|
|
// because nothing else keeps the event loop alive -- `npx impeccable install`
|
|
// exits 0 with no error, silently installing a fraction of the bundle. This
|
|
// fixture carries 84 files (well past the ~31 entry stall point), so a
|
|
// unpacker that stops early fails the count assertion here instead of
|
|
// shipping silently. fflate decompresses in-memory and is immune.
|
|
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-large-'));
|
|
const written = makeLargeUniversalTree(dist);
|
|
|
|
await createAllZips(dist);
|
|
|
|
const zipPath = join(dist, 'universal.zip');
|
|
assert.ok(existsSync(zipPath), 'universal.zip was not created');
|
|
assert.ok(statSync(zipPath).size > 0, 'universal.zip is empty (0 bytes)');
|
|
|
|
// Round-trip through the exact code path downloadAndExtractBundle runs.
|
|
const out = mkdtempSync(join(tmpdir(), 'imp-unzip-large-'));
|
|
await extractZip(zipPath, out);
|
|
|
|
const extracted = countFiles(out);
|
|
assert.equal(extracted, written, `partial extraction: only ${extracted} of ${written} files unpacked`);
|
|
|
|
rmSync(dist, { recursive: true, force: true });
|
|
rmSync(out, { recursive: true, force: true });
|
|
});
|
|
|
|
it('rejects a zip entry whose path escapes the target dir (zip-slip)', async () => {
|
|
// extractZip writes entries itself, so it must refuse `../` traversal that a
|
|
// malicious or malformed archive could use to land files outside targetDir.
|
|
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-slip-'));
|
|
const { zipSync, strToU8 } = await import('fflate');
|
|
const zipped = zipSync({ '../escaped.txt': strToU8('pwned\n') });
|
|
const zipPath = join(dist, 'evil.zip');
|
|
writeFileSync(zipPath, zipped);
|
|
|
|
const out = mkdtempSync(join(tmpdir(), 'imp-unzip-slip-'));
|
|
await assert.rejects(() => extractZip(zipPath, out), /outside target dir/);
|
|
assert.ok(!existsSync(join(dist, 'escaped.txt')), 'zip-slip entry escaped the target dir');
|
|
|
|
rmSync(dist, { recursive: true, force: true });
|
|
rmSync(out, { recursive: true, force: true });
|
|
});
|
|
|
|
it('createProviderZip throws when the source has no files (no silent 0-byte artifact)', async () => {
|
|
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-empty-'));
|
|
mkdirSync(join(dist, 'universal'), { recursive: true });
|
|
|
|
await assert.rejects(
|
|
() => createProviderZip(join(dist, 'universal'), dist, 'universal'),
|
|
/no entries|0 bytes/i,
|
|
);
|
|
|
|
rmSync(dist, { recursive: true, force: true });
|
|
});
|
|
|
|
it('createProviderZip throws when the source directory is missing', async () => {
|
|
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-missing-'));
|
|
|
|
await assert.rejects(
|
|
() => createProviderZip(join(dist, 'does-not-exist'), dist, 'universal'),
|
|
/not found/i,
|
|
);
|
|
|
|
rmSync(dist, { recursive: true, force: true });
|
|
});
|
|
});
|