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

  1. Re-download the release zip from the exact GitHub release URL and confirm its SHA-256 digest against the release metadata before calling validateReleaseZip.
  2. 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`.
  3. Rebuild the artifact with a standard zip writer (zip/Info-ZIP or the packaging pipeline) rather than a hand-rolled or post-processed archive.
  4. 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

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


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)