Corrupted ZIP after a multipart export: where the bytes went wrong
Assemble now refuses a manifest that doesn't match the upload, which narrows a broken archive to three causes. A round-trip digest check that catches all three.
A customer opens their export and unzip reports an unexpected end of archive. Start from what the platform guarantees: on Infrai, POST /v1/storage/multipart/complete/{upload_id} validates the manifest against the parts it actually received, so a list that’s short a part, repeats a number or carries a stale etag is rejected with STORAGE_MULTIPART_INCONSISTENT rather than assembled. A 200 there means the object is exactly the parts you uploaded.
Which is useful, because it removes the scariest hypothesis and leaves three ordinary ones.
The three places corruption can still enter
| Where | Symptom on the customer’s disk | How you prove it |
|---|---|---|
| The ZIP you generated was already broken | unzip -t fails on your own build machine too | Test the archive before it leaves the exporter |
| A part carried the wrong bytes — an offset or length bug in your chunker | Object size matches, contents don’t | Digest the object after upload and compare |
| The download truncated in transit | Object size is right, the saved file is smaller | Compare size_bytes against the bytes on disk |
Note what isn’t on that list any more: a silently short assembly. The manifest check makes that a 409 you’ll see in your job logs, not a mystery in a support ticket.
Step one: what does storage think it’s holding?
head is free, needs no signature, and answers in a couple of hundred milliseconds:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-exports-0726/exports/2026-07/report.zip" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "exports/2026-07/report.zip",
"size_bytes": 7340032,
"etag": "1272f751990a94a9f44a86fc888b0062-2",
"content_type": "application/zip",
"last_modified": "2026-07-27T11:53:41Z"
}
}
Two numbers do the triage. size_bytes against the file your exporter produced tells you whether the right quantity of bytes arrived. The digits after the hyphen in etag — -2 here — are the part count the object was assembled from, so if your uploader sent four chunks and this says -2, the bug is upstream in your chunking loop rather than anywhere near storage.
If found comes back false, stop reading about ZIPs. The write never landed and you’re debugging the wrong half of the pipeline.
Step two: round-trip the object and compare digests
Size and part count can both be right while the contents are wrong — that’s what an off-by-one in a chunk read looks like. The only test that settles it is reading the object back and hashing it. GET /v1/storage/object/get/{bucket}/{key} returns the bytes base64-encoded inside the JSON envelope:
curl -sS "https://api.infrai.cc/v1/storage/object/get/kb-exports-0726/exports/2026-07/report.zip" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "exports/2026-07/report.zip",
"size_bytes": 22,
"data_base64": "UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA=="
}
}
Wire it into the export job as a verification pass. The digest you compare against is the one you computed while writing the file, so nothing about this depends on trusting the upload path:
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BUCKET = "kb-exports-0726";
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
export async function verifyExport(objectKey, localPath) {
const local = await readFile(localPath);
const res = await fetch(`${API}/v1/storage/object/get/${BUCKET}/${objectKey}`, {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
const out = await res.json();
if (!res.ok || out.ok === false) {
throw new Error(`read back failed: ${out?.error?.code ?? res.status}`);
}
if (!out.data.found) throw new Error(`${objectKey} is not in the bucket`);
const stored = Buffer.from(out.data.data_base64, "base64");
if (stored.length !== local.length) {
throw new Error(`stored ${stored.length} bytes, built ${local.length}`);
}
const a = sha256(stored);
const b = sha256(local);
if (a !== b) throw new Error(`digest mismatch: stored ${a.slice(0, 12)}… built ${b.slice(0, 12)}…`);
return { key: objectKey, bytes: stored.length, sha256: a };
}
console.log(await verifyExport("exports/2026-07/report.zip", "./report.zip"));
Run it once per export, not once per download. It reads the whole object, so it’s the one step in this pipeline that costs real money — see the last section — and on a nightly batch of a few dozen archives it’s still cents.
The chunker bug this catches
Almost every “right size, wrong bytes” report we’ve traced comes back to the read loop. The shape that works:
import { open, stat } from "node:fs/promises";
const PART_SIZE = 8 * 1024 * 1024;
export async function* chunks(path) {
const { size } = await stat(path);
const file = await open(path, "r");
try {
let offset = 0;
for (let partNumber = 1; offset < size; partNumber++) {
const length = Math.min(PART_SIZE, size - offset);
const buffer = Buffer.alloc(length);
const { bytesRead } = await file.read(buffer, 0, length, offset);
if (bytesRead !== length) throw new Error(`part ${partNumber}: read ${bytesRead} of ${length}`);
yield { partNumber, buffer };
offset += length;
}
} finally {
await file.close();
}
}
Three guards in nine lines: the offset advances by what was actually requested, bytesRead is checked rather than assumed, and the handle closes on the failure path. A loop that reuses one Buffer across iterations, or that ignores a short read near the end of the file, produces parts that upload happily and assemble into an archive whose central directory points at the wrong offsets.
Announce the export only after the digest matches. That last step is POST /v1/email/send on the same key that ran the upload — one credential covering storage, the queue that scheduled the job and the mail that tells the customer it’s ready, with a single usage view instead of three vendor invoices to line up.
Cleaning up what didn’t finish
An upload you walk away from keeps its parts and keeps costing you, and they don’t appear in an object listing. Abort explicitly:
UPLOAD_ID="178515273680da5ef4949412b1603fd753f63c17f430b2462bf44cb4d31757179"
curl -sS -X DELETE "https://api.infrai.cc/v1/storage/multipart/abort/${UPLOAD_ID}" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Then confirm the prefix holds only what you expect. Listing is free, and each row carries size and content type:
curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-exports-0726?prefix=exports/2026-07/&limit=50" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Limits, alternatives and what the read costs
The trade-off in the verification pass is that reads are metered by volume, so digesting every export doubles the bytes you move. Verified 27 July 2026, GET /v1/storage/object/get/{bucket}/{key} bills $0.104 per GB of response body — the meter reads the actual bytes returned, and the ledger entry for a read shows up in the response metadata as cost_usd. The upload side is per call instead: PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 for a part. New accounts start with $2 of credit. Read the live figures instead of these:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free'), c['billing'].get('unit','')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"
Rates drift downward and campaigns run, so what you find may well be lower. The structural point survives any repricing: per-call fees on a multipart upload are noise, and egress volume is the number that scales with your customers’ download habits.
Two boundaries worth stating plainly. part_size_min comes back as 5,242,880 bytes and is advisory — a 1 MiB non-final part assembled fine in our testing, where Amazon S3 answers EntityTooSmall — so assert your chunk size yourself. And there’s no server-side content checksum beyond the part-count etag, which is exactly why the digest pass above exists rather than a flag you could switch on. If you’d rather not build any of this, a self-hosted MinIO plus the S3 SDK’s managed uploader handles chunking, retries and integrity for you, and that’s a reasonable pick when uploading is the whole job.