Hmbown/CodeWhale · error · Error
Invalid update entry.
Error message
Invalid update entry.
What it means
validateReleaseZip manually parses the ZIP central directory without an archive library. Before reading each central-directory header it checks that the full 46-byte fixed header fits before the end-of-central-directory record and that the bytes at the cursor carry the central-directory file-header signature 0x02014b50 (PK\x01\x02). Either check failing means the archive's central directory is corrupt, truncated, or points at garbage, so the library refuses to trust any further offsets.
Solutions
- Re-download the release zip from the exact GitHub release URL and confirm its SHA-256 digest against the release metadata before calling validateReleaseZip.
- Verify the file is a real zip: check the bytes start with PK\x03\x04 and end with a valid EOCD signature PK\x05\x06, e.g. with `unzip -t file.zip`.
- Rebuild the artifact with a standard zip writer (zip/Info-ZIP or the packaging pipeline) rather than a hand-rolled or post-processed archive.
- If the error appears in tests, regenerate the test fixture zip — it was likely truncated or hand-edited.
Example fix
// before
const bytes = fs.readFileSync(args[2]);
validateReleaseZip(bytes);
// after
const bytes = fs.readFileSync(args[2]);
if (bytes.subarray(0,4).toString("latin1") !== "PK\x03\x04") throw new Error("Not a zip file — check the download URL/digest.");
validateReleaseZip(bytes); Defensive patterns
Strategy: validation
Validate before calling
const bytes = fs.readFileSync(zipPath);
if (bytes.length < 22 || bytes.subarray(0,4).toString("latin1") !== "PK\x03\x04") throw new Error("Not a zip file"); Type guard
function looksLikeZip(buf){return Buffer.isBuffer(buf)&&buf.length>=22&&buf.readUInt32LE(0)===0x04034b50;} Try / catch
try { validateReleaseZip(bytes); } catch (e) { if (e.message === "Invalid update entry.") { /* re-download / republish artifact */ } else throw e; } Prevention
- Verify the downloaded asset's SHA-256 before validating
- Smoke-test the artifact with `unzip -t` in CI before publishing
- Never truncate or post-process the zip after writing
- Fetch only from the canonical GitHub release URL
When it happens
Trigger: Calling validateReleaseZip on bytes whose central-directory `position` cursor walks past `end` (a 46-byte header would cross the EOCD) or where the signature at `position` is not 0x02014b50 — e.g. a truncated download, a self-extracting or non-ZIP file, or a zip whose central directory offset fields don't match its EOCD.
Common situations: Serving a partially downloaded or HTML error page instead of the release zip; a build step (some incremental/staged zip writers) emitting a central directory that disagrees with the file; tampered or fuzzed update payloads; wrong file fetched from a mirror.
Related errors
- Inconsistent update file header.
- Invalid update archive length.
- Invalid update file header.
- The update entry size did not match its contents.
- Inconsistent update sizes or compression.
AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22).
Data as JSON: /api/errors/9800ad8603c58f5e.
Report an issue: GitHub.
Appendix: source
Thrown at crates/tui/plugins/computer-use/app/updates.mjs:58
return {available:true,version,url,sha256:asset.digest.slice(7),size:asset.size,message:`Computer Use ${version} is available. Install it to restart the helper; existing computer sessions will stop.`};
}
export async function checkForUpdate() {
const response=await fetch("https://api.github.com/repos/Hmbown/codewhale-cu-plugin/releases/latest",{redirect:"error",headers:{Accept:"application/vnd.github+json","X-GitHub-Api-Version":"2022-11-28"},signal:AbortSignal.timeout(10_000)});
if(response.status===404) return {available:false,message:"No stable installer has been published yet. Your current app is unchanged."};
if(!response.ok) throw new Error(`The update service is unavailable (${response.status}). Try again later.`);
return releaseUpdate(JSON.parse((await responseBytes(response,1024*1024)).toString("utf8")));
}
/** Inspect both ZIP headers before extraction: no links, traversal or bombs. */
export function validateReleaseZip(bytes) {
const minimum=Math.max(0,bytes.length-65557); let end=-1;
for(let i=bytes.length-22;i>=minimum;i--) if(bytes.readUInt32LE(i)===0x06054b50&&i+22+bytes.readUInt16LE(i+20)===bytes.length) { end=i; break; }
if(end<0||bytes.readUInt16LE(end+4)||bytes.readUInt16LE(end+6)) throw new Error("Invalid update archive.");
const count=bytes.readUInt16LE(end+10); let position=bytes.readUInt32LE(end+16),total=0;
if(!count||count>2000||bytes.readUInt16LE(end+8)!==count||position+bytes.readUInt32LE(end+12)!==end) throw new Error("Invalid update archive index.");
const seen=new Set();
for(let i=0;i<count;i++) {
if(position+46>end||bytes.readUInt32LE(position)!==0x02014b50) throw new Error("Invalid update entry.");
const flags=bytes.readUInt16LE(position+8),method=bytes.readUInt16LE(position+10),length=bytes.readUInt16LE(position+28),extra=bytes.readUInt16LE(position+30),comment=bytes.readUInt16LE(position+32);
const name=bytes.subarray(position+46,position+46+length).toString("utf8");
const kind=(bytes.readUInt32LE(position+38)>>>16)&0xf000,offset=bytes.readUInt32LE(position+42),compressed=bytes.readUInt32LE(position+20);
const size=bytes.readUInt32LE(position+24); total+=size;
if(flags&1||![0,8].includes(method)||![0,0x4000,0x8000].includes(kind)||total>512*1024*1024||position+46+length+extra+comment>end) throw new Error("Unsupported update entry.");
if(!name.startsWith(`${APP_NAME}.app/`)||name.includes("\\")||name.includes(":")||name.includes("\0")||name.split("/").some(part=>part===".."||part===".")||seen.has(name)) throw new Error("Unsafe update path.");
seen.add(name);
if(offset+30>position||bytes.readUInt32LE(offset)!==0x04034b50) throw new Error("Invalid update file header.");
const localLength=bytes.readUInt16LE(offset+26),localExtra=bytes.readUInt16LE(offset+28);
if(offset+30+localLength+localExtra+compressed>bytes.readUInt32LE(end+16)||bytes.subarray(offset+30,offset+30+localLength).toString("utf8")!==name) throw new Error("Inconsistent update file header.");
if(bytes.readUInt16LE(offset+8)!==method||bytes.readUInt16LE(offset+6)!==flags||(!(flags&8)&&(bytes.readUInt32LE(offset+18)!==compressed||bytes.readUInt32LE(offset+22)!==size))) throw new Error("Inconsistent update sizes or compression.");
const start=offset+30+localLength+localExtra;
// Header sizes are untrusted. Bound actual expansion before ditto writes
// anything, including a compressed payload whose headers understate size.
const payload=bytes.subarray(start,start+compressed);
let expanded;
try { expanded=method===0?payload.length:inflateRawSync(payload,{maxOutputLength:Math.max(size,1)}).length; }
catch { throw new Error("Invalid or oversized compressed update entry."); }View on GitHub (pinned to 73e0f67d83)