Hmbown/CodeWhale · error · Error

Invalid update archive index.

Error message

Invalid update archive index.

What it means

After locating the EOCD, validateReleaseZip parses the central directory: entry count must be 1–2000, the on-disk count must match the EOCD count, and the central-directory offset plus size must land exactly on the EOCD. Any mismatch throws 'Invalid update archive index.', indicating a corrupt or maliciously crafted directory structure.

Solutions

  1. Re-download and SHA-256-verify the release asset before validating
  2. Compare the asset's byte size against the release metadata's declared size (checkForUpdate already validates this) — a mismatch means truncation
  3. Rebuild the archive with a standard tool (zip -r) if it was produced by a custom packer emitting inconsistent metadata
  4. Reject the update and keep the current install — the parser intentionally fails closed

Example fix

// before (validate without size cross-check)
code.validateReleaseZip(bytes);
// after (cross-check declared size first)
if (bytes.length !== releaseInfo.size) throw new Error("archive size differs from release metadata");
code.validateReleaseZip(bytes);
Defensive patterns

Strategy: validation

Validate before calling

function centralDirectorySane(bytes, eocd) {
  const count = bytes.readUInt16LE(eocd + 10);
  const size = bytes.readUInt32LE(eocd + 12), off = bytes.readUInt32LE(eocd + 16);
  return count > 0 && count <= 2000 && off + size === eocd;
}

Try / catch

try {
  validateReleaseZip(bytes);
} catch (e) {
  if (e.message.includes("archive index")) {
    quarantineArchive(bytes); // inconsistent index; treat as hostile
  } else throw e;
}

Prevention

When it happens

Trigger: Calling validateReleaseZip on an archive with zero entries, more than 2000 entries, a central-directory count inconsistent with the EOCD, or an offset+size that does not end at the EOCD — typical of truncation or hand-crafted hostile ZIPs.

Common situations: Truncated/partially extracted download cutting off the central directory; attacker-supplied archive with a lying index (ZIP-bomb precursor); a packaging tool emitting inconsistent metadata; an empty release archive shipped by mistake.

Understand the failure class

Background: Checksum mismatch errors: "checksum verification failed", "digest mismatch", "expected vs actual checksum" — what they mean and how to fix them — this error's family across 41 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/3b4c7fafcf3b5210. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/plugins/computer-use/app/updates.mjs:55

  const asset=release.assets?.find(asset=>asset.name===name);
  const url=`${repository}/releases/download/v${version}/${name}`;
  if(!asset||asset.browser_download_url!==url||!/^sha256:[a-f0-9]{64}$/.test(asset.digest)||!Number.isSafeInteger(asset.size)||asset.size<=0||asset.size>limit) return {available:false,message:`Version ${version} has no verified macOS installer yet.`};
  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);

View on GitHub (pinned to 73e0f67d83)