{"record":{"id":"ff1ec68a76cd6514","repo":"can1357/oh-my-pi","slug":"invalid-zip-archive-truncated-end-of-central-dire","errorCode":null,"errorMessage":"Invalid ZIP archive: truncated end of central directory","messagePattern":"Invalid ZIP archive: truncated end of central directory","errorType":"exception","errorClass":"ArchiveError","httpStatus":null,"severity":"error","filePath":"packages/utils/src/ar/zip.ts","lineNumber":185,"sourceCode":"\tif (info.entries === 0) return info.offset;\n\tconst candidates = [info.offset];\n\tconst adjacent = info.physicalEnd - info.size;\n\tif (adjacent !== info.offset) candidates.push(adjacent);\n\tfor (const offset of candidates) {\n\t\tif (offset < 0 || offset + 4 > source.size || offset + info.size > source.size) continue;\n\t\tconst signature = await source.read(offset, offset + 4);\n\t\tif (signature.byteLength === 4 && readUInt32LE(signature, 0) === CENTRAL_HEADER_SIGNATURE) return offset;\n\t}\n\tthrow new ArchiveError(\"Invalid ZIP archive: central directory is out of bounds or malformed\");\n}\n\nasync function readCentralDirectoryInfo(source: ByteSource, limits: ArchiveLimits): Promise<CentralDirectoryInfo> {\n\tif (source.size < EOCD_LENGTH) throw new ArchiveError(\"Invalid ZIP archive: missing end of central directory\");\n\tconst tailLength = Math.min(source.size, EOCD_LENGTH + MAX_COMMENT_LENGTH);\n\tconst tailStart = source.size - tailLength;\n\tconst tail = await source.read(tailStart, source.size);\n\tif (tail.byteLength !== tailLength)\n\t\tthrow new ArchiveError(\"Invalid ZIP archive: truncated end of central directory\");\n\tconst eocdIndex = findEocd(tail);\n\tconst eocdOffset = tailStart + eocdIndex;\n\tconst disk = readUInt16LE(tail, eocdIndex + 4);\n\tconst centralDisk = readUInt16LE(tail, eocdIndex + 6);\n\tconst entriesOnDisk = readUInt16LE(tail, eocdIndex + 8);\n\tlet entries = readUInt16LE(tail, eocdIndex + 10);\n\tlet size = readUInt32LE(tail, eocdIndex + 12);\n\tlet offset = readUInt32LE(tail, eocdIndex + 16);\n\tif (\n\t\tdisk !== 0 ||\n\t\tcentralDisk !== 0 ||\n\t\t(entriesOnDisk !== U16_MAX && entries !== U16_MAX && entriesOnDisk !== entries)\n\t) {\n\t\tthrow new ArchiveError(\"Multi-volume ZIP archives are not supported\");\n\t}\n\tconst needsZip64 = entriesOnDisk === U16_MAX || entries === U16_MAX || size === U32_MAX || offset === U32_MAX;\n\tconst zip64 = await readZip64Info(source, tail, tailStart, eocdOffset);\n\tlet physicalEnd = eocdOffset;","sourceCodeStart":167,"sourceCodeEnd":203,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/utils/src/ar/zip.ts#L167-L203","documentation":"Thrown by readCentralDirectoryInfo when the reader asks the ByteSource for the tail region (up to EOCD + max ZIP comment length) but receives fewer bytes than requested. This indicates the underlying source shrank or cannot serve its own declared size — the file changed between the size check and the read.","triggerScenarios":"Calling zip info/read on a source whose `size` reports more bytes than a read of [tailStart, size) actually returns: file truncated concurrently by another process, a network/device-backed source that returns short reads, or a custom ByteSource implementation violating its contract.","commonSituations":"Reading a zip while a download/copy is still in progress; a log-rotation or cleanup job truncating the file mid-read; a buggy custom ByteSource (e.g. streaming reader) that reports optimistic sizes.","solutions":["Retry after confirming the file is no longer being written (check for stable size across a short delay).","Copy the file to a stable local path first, then open the copy.","Re-download the archive; this state usually means the file is incomplete.","If you implemented a custom ByteSource, ensure read(start,end) returns exactly end-start bytes or throws — never a short buffer."],"exampleFix":"// before: reading in place while the writer is active\nconst zip = readZip(openByteSource(possiblyGrowingPath));\n// after: wait for the writer to finish (stable size) or snapshot\nlet prev = -1;\nwhile (true) {\n  const size = statSync(path).size;\n  if (size === prev && size > 0) break;\n  prev = size;\n  await Bun.sleep(500);\n}\nconst stable = await Bun.file(path).arrayBuffer();\nconst zip = readZip(memoryByteSource(new Uint8Array(stable)));","handlingStrategy":"try-catch","validationCode":"import * as fs from \"node:fs/promises\";\n// Read to a stable in-memory snapshot first so the parser never races a writer.\nconst bytes = new Uint8Array(await Bun.file(path).arrayBuffer());\nconst statSize = (await fs.stat(path)).size;\nif (bytes.byteLength !== statSize) throw new Error(\"file changed during read; retry\");","typeGuard":null,"tryCatchPattern":"try {\n  const zip = readZip(memoryByteSource(snapshotBytes));\n} catch (err) {\n  if (err instanceof ArchiveError && err.message.includes(\"truncated end of central directory\")) {\n    await Bun.sleep(1000); // producer may still be writing; retry once with a fresh snapshot\n    return openZipWithSnapshot(path);\n  }\n  throw err;\n}","preventionTips":["Snapshot the archive into memory or a temp copy before parsing if any other process may write it.","If you implement a custom ByteSource, never return short reads — serve exactly the requested range or throw.","Poll for a stable file size before opening archives produced asynchronously.","Avoid reading archives from mounts/locations with concurrent cleanup jobs."],"tags":["zip","archive","truncation","concurrency"],"backgroundTag":"zip-truncated-eocd","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}