mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(daemon): unbreak Claude Design ZIP import on Node 24 and raise file ceiling (#591)
* fix(daemon): unbreak Claude Design ZIP import on Node 24 and raise file ceiling
- Skip inflateRawSync when an entry's central-directory uncompressedSize is 0;
Node 24 rejects { maxOutputLength: 0 } with ERR_OUT_OF_RANGE, which silently
killed the entire import for any zip containing an empty file or a streaming
entry whose size is only present in the data descriptor.
- Raise MAX_FILES from 500 to 5000. Real-world design-system exports commonly
exceed 500 files; MAX_TOTAL_BYTES (100 MB) and MAX_FILE_BYTES (25 MB) already
cap pathological inputs.
- Add regression tests for both: zero-byte deflate entry, central directory
advertising uncompressedSize=0, and a 600-file zip.
Refs #590
* fix(daemon): preserve real payload when central uncompressedSize is 0
Reviewers correctly flagged that the previous Buffer.alloc(0) fast-path
trusted the central directory's uncompressedSize, which is unreliable for
streaming/data-descriptor zips: an entry whose central record reports 0
can still carry real deflated bytes. The earlier fix wrote empty files to
disk in that case, and the post-condition body.length !== uncompressedSize
check still passed because both sides were 0.
- Inflate streaming entries with maxOutputLength = MAX_FILE_BYTES when the
central directory advertises 0, so legitimate non-empty payloads decode
fully instead of being silently truncated.
- Move size enforcement post-decode: per-file and total-byte budgets are now
computed from the actual decoded length, and the strict equality check is
skipped when central was 0 (i.e., genuinely unknown).
- Keep the empty-deflate degenerate case (compressed.length === 0) safe by
short-circuiting before zlib instead of relying on uncompressedSize.
Tests:
- New: streaming-zip case with central uncompressedSize=0 + non-empty body
asserts the on-disk file matches the original bytes (would have been
silently truncated under the previous fix).
- New: oversized streaming entry (> MAX_FILE_BYTES) is still rejected even
though the central directory under-reports.
- The original 0-byte and >500-file regressions remain covered.
This commit is contained in:
@@ -8,7 +8,7 @@ const EOCD_SIG = 0x06054b50;
|
||||
const CENTRAL_SIG = 0x02014b50;
|
||||
const LOCAL_SIG = 0x04034b50;
|
||||
|
||||
const MAX_FILES = 500;
|
||||
const MAX_FILES = 5000;
|
||||
const MAX_TOTAL_BYTES = 100 * 1024 * 1024;
|
||||
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
@@ -25,13 +25,21 @@ export async function importClaudeDesignZip(zipPath, projectDir) {
|
||||
if (entry.uncompressedSize > MAX_FILE_BYTES) {
|
||||
throw new Error(`zip file too large: ${relPath}`);
|
||||
}
|
||||
totalBytes += entry.uncompressedSize;
|
||||
if (totalBytes > MAX_TOTAL_BYTES) throw new Error('zip is too large');
|
||||
|
||||
// Decode first; the central directory's uncompressedSize is unreliable for
|
||||
// streaming/data-descriptor zips (it can read 0 even when the payload
|
||||
// carries real data). The inflate cap and the post-decode size checks below
|
||||
// are authoritative.
|
||||
const body = readEntryBody(zip, entry);
|
||||
if (body.length !== entry.uncompressedSize) {
|
||||
if (body.length > MAX_FILE_BYTES) {
|
||||
throw new Error(`zip file too large: ${relPath}`);
|
||||
}
|
||||
if (entry.uncompressedSize > 0 && body.length !== entry.uncompressedSize) {
|
||||
throw new Error(`zip entry size mismatch: ${relPath}`);
|
||||
}
|
||||
totalBytes += body.length;
|
||||
if (totalBytes > MAX_TOTAL_BYTES) throw new Error('zip is too large');
|
||||
|
||||
files.push({ path: relPath, body });
|
||||
}
|
||||
|
||||
@@ -113,7 +121,16 @@ function readEntryBody(zip, entry) {
|
||||
if (bodyEnd > zip.length) throw new Error(`zip entry exceeds archive: ${entry.name}`);
|
||||
const compressed = zip.slice(bodyStart, bodyEnd);
|
||||
if (entry.method === 0) return Buffer.from(compressed);
|
||||
return inflateRawSync(compressed, { maxOutputLength: entry.uncompressedSize });
|
||||
// A genuinely empty deflate payload would still occupy at least the BFINAL
|
||||
// marker; an entirely missing payload cannot be inflated, so treat it as
|
||||
// empty rather than handing a zero-length buffer to zlib.
|
||||
if (compressed.length === 0) return Buffer.alloc(0);
|
||||
// When the central directory advertises 0 (streaming zips with data
|
||||
// descriptors), fall back to the per-file ceiling so legitimate non-empty
|
||||
// payloads decode instead of being silently truncated. The post-decode
|
||||
// checks in the caller enforce MAX_FILE_BYTES and total-bytes limits.
|
||||
const cap = entry.uncompressedSize > 0 ? entry.uncompressedSize : MAX_FILE_BYTES;
|
||||
return inflateRawSync(compressed, { maxOutputLength: cap });
|
||||
}
|
||||
|
||||
function sanitizeZipPath(name) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { importClaudeDesignZip } from '../src/claude-design-import.js';
|
||||
|
||||
function buildZip(
|
||||
entries: { name: string; body: Buffer; method?: 0 | 8; falsifyCentralUncompressed?: boolean }[],
|
||||
): Buffer {
|
||||
const localChunks: Buffer[] = [];
|
||||
const centralChunks: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const method = entry.method ?? 8;
|
||||
const nameBuf = Buffer.from(entry.name, 'utf8');
|
||||
const compressed = method === 0 ? entry.body : deflateRawSync(entry.body);
|
||||
const crcBuf = Buffer.alloc(4);
|
||||
// CRC isn't validated by the importer; zero is fine for this test fixture.
|
||||
crcBuf.writeUInt32LE(0, 0);
|
||||
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4); // version needed
|
||||
local.writeUInt16LE(0, 6); // flags
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt16LE(0, 10); // mod time
|
||||
local.writeUInt16LE(0, 12); // mod date
|
||||
crcBuf.copy(local, 14);
|
||||
local.writeUInt32LE(compressed.length, 18);
|
||||
local.writeUInt32LE(entry.body.length, 22);
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
local.writeUInt16LE(0, 28); // extra len
|
||||
localChunks.push(local, nameBuf, compressed);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4); // version made by
|
||||
central.writeUInt16LE(20, 6); // version needed
|
||||
central.writeUInt16LE(0, 8); // flags
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt16LE(0, 12);
|
||||
central.writeUInt16LE(0, 14);
|
||||
crcBuf.copy(central, 16);
|
||||
central.writeUInt32LE(compressed.length, 20);
|
||||
// The central directory may legitimately advertise uncompressedSize=0 even when
|
||||
// the local header has the real length (streaming zips with data descriptors).
|
||||
// Reproduce that case explicitly when requested.
|
||||
central.writeUInt32LE(
|
||||
entry.falsifyCentralUncompressed ? 0 : entry.body.length,
|
||||
24,
|
||||
);
|
||||
central.writeUInt16LE(nameBuf.length, 28);
|
||||
central.writeUInt16LE(0, 30);
|
||||
central.writeUInt16LE(0, 32);
|
||||
central.writeUInt16LE(0, 34);
|
||||
central.writeUInt16LE(0, 36);
|
||||
central.writeUInt32LE(0, 38);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
centralChunks.push(central, nameBuf);
|
||||
|
||||
offset += local.length + nameBuf.length + compressed.length;
|
||||
}
|
||||
|
||||
const localBlob = Buffer.concat(localChunks);
|
||||
const centralBlob = Buffer.concat(centralChunks);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4);
|
||||
eocd.writeUInt16LE(0, 6);
|
||||
eocd.writeUInt16LE(entries.length, 8);
|
||||
eocd.writeUInt16LE(entries.length, 10);
|
||||
eocd.writeUInt32LE(centralBlob.length, 12);
|
||||
eocd.writeUInt32LE(localBlob.length, 16);
|
||||
eocd.writeUInt16LE(0, 20);
|
||||
return Buffer.concat([localBlob, centralBlob, eocd]);
|
||||
}
|
||||
|
||||
describe('importClaudeDesignZip', () => {
|
||||
it('imports zips that contain a zero-byte deflate entry without crashing on Node 24', async () => {
|
||||
// Regression: inflateRawSync rejects { maxOutputLength: 0 } on Node 24.
|
||||
const zip = buildZip([
|
||||
{ name: 'index.html', body: Buffer.from('<html></html>') },
|
||||
{ name: 'docs/empty.md', body: Buffer.alloc(0) },
|
||||
]);
|
||||
const tmp = mkdtempSync(path.join(os.tmpdir(), 'cd-import-'));
|
||||
const zipPath = path.join(tmp, 'in.zip');
|
||||
const projectDir = path.join(tmp, 'proj');
|
||||
writeFileSync(zipPath, zip);
|
||||
try {
|
||||
const result = await importClaudeDesignZip(zipPath, projectDir);
|
||||
expect(result.entryFile).toBe('index.html');
|
||||
expect(result.files.sort()).toEqual(['docs/empty.md', 'index.html']);
|
||||
const empty = readFileSync(path.join(projectDir, 'docs/empty.md'));
|
||||
expect(empty.length).toBe(0);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the real payload when the central directory under-reports size to 0', async () => {
|
||||
// Streaming zips (data descriptor, flag bit 3) legitimately leave central
|
||||
// uncompressedSize = 0 while the payload carries real bytes. Earlier
|
||||
// attempts to "fast-path" those entries silently truncated valid files;
|
||||
// verify the actual deflated content is decoded and written through.
|
||||
const realBody = Buffer.from(
|
||||
'# streamed entry\n\n' + 'x'.repeat(4096) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
const zip = buildZip([
|
||||
{ name: 'index.html', body: Buffer.from('<html></html>') },
|
||||
{
|
||||
name: 'docs/streamed.md',
|
||||
body: realBody,
|
||||
falsifyCentralUncompressed: true,
|
||||
},
|
||||
]);
|
||||
const tmp = mkdtempSync(path.join(os.tmpdir(), 'cd-import-'));
|
||||
const zipPath = path.join(tmp, 'in.zip');
|
||||
const projectDir = path.join(tmp, 'proj');
|
||||
writeFileSync(zipPath, zip);
|
||||
try {
|
||||
const result = await importClaudeDesignZip(zipPath, projectDir);
|
||||
expect(result.files).toContain('docs/streamed.md');
|
||||
const written = readFileSync(path.join(projectDir, 'docs/streamed.md'));
|
||||
expect(written.equals(realBody)).toBe(true);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects entries that decode larger than MAX_FILE_BYTES even when central size is 0', async () => {
|
||||
// The central directory cannot be trusted to enforce the per-file ceiling
|
||||
// for streaming zips. Build a fixture whose decoded payload is just barely
|
||||
// beyond the limit and confirm we still fail closed.
|
||||
const oversized = Buffer.alloc(25 * 1024 * 1024 + 1, 0x61);
|
||||
const zip = buildZip([
|
||||
{ name: 'index.html', body: Buffer.from('<html></html>') },
|
||||
{
|
||||
name: 'docs/oversize.bin',
|
||||
body: oversized,
|
||||
falsifyCentralUncompressed: true,
|
||||
},
|
||||
]);
|
||||
const tmp = mkdtempSync(path.join(os.tmpdir(), 'cd-import-'));
|
||||
const zipPath = path.join(tmp, 'in.zip');
|
||||
const projectDir = path.join(tmp, 'proj');
|
||||
writeFileSync(zipPath, zip);
|
||||
try {
|
||||
await expect(importClaudeDesignZip(zipPath, projectDir)).rejects.toThrow();
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts zips with more than the previous 500-file ceiling', async () => {
|
||||
// Regression: design-system exports commonly exceed 500 files.
|
||||
const entries = [{ name: 'index.html', body: Buffer.from('<html></html>') }];
|
||||
for (let i = 0; i < 600; i += 1) {
|
||||
entries.push({ name: `assets/icon-${i}.svg`, body: Buffer.from(`<svg>${i}</svg>`) });
|
||||
}
|
||||
const zip = buildZip(entries);
|
||||
const tmp = mkdtempSync(path.join(os.tmpdir(), 'cd-import-'));
|
||||
const zipPath = path.join(tmp, 'in.zip');
|
||||
const projectDir = path.join(tmp, 'proj');
|
||||
writeFileSync(zipPath, zip);
|
||||
try {
|
||||
const result = await importClaudeDesignZip(zipPath, projectDir);
|
||||
expect(result.entryFile).toBe('index.html');
|
||||
expect(readdirSync(path.join(projectDir, 'assets')).length).toBe(600);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user