storebound/cli/lib/zip.ts
CRT d972c36fa8 Merge store-ops: TestFlight wait / expire, IAP review submissions, pulls, offers, CPP, events, reports, triage, ASO rank, ads scan
Conflicts resolved for both sides:
- cli/lib/plist.ts, cli/lib/zip.ts: one implementation each. plist reads XML (text or
  bytes, attributes tolerated) and binary plists; ads scan now reads bytes, so a binary
  Info.plist works too. zip gains unzip() for the Play report exports on top of
  zipEntries / zipRead.
- apple upload waits with testflight wait's loop (waitForBuild) instead of its own copy;
  BUILD_DONE removed.
- config, credentials (profiles, keystore, ASC_VENDOR_NUMBER, now also a profile key),
  command table, MCP tools, credentials template, README, CHANGELOG: union.
- MCP: --write stays forbidden; the three pull tools are the listed local-file exception
  (LOCAL_WRITE_TOOLS), so scan --write remains unreachable.
- Adapters and API coverage tables regenerated.
2026-10-12 00:07:26 +08:00

59 lines
3.5 KiB
TypeScript

// Reads entries of a ZIP archive without dependencies: the central directory, then one entry, stored or deflated
// (node:zlib, built into Bun). Used for an .ipa / .aab (Payload/<App>.app/Info.plist, the manifest) and the Play
// financial report exports (earnings_YYYYMM.zip, salesreport_YYYYMM.zip: one CSV inside). No ZIP64, no encryption.
import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
import { inflateRawSync } from 'node:zlib';
export interface ZipEntry { name: string; method: number; compressedSize: number; size: number; localOffset: number }
function readAt(fd: number, pos: number, len: number): Buffer {
const b = Buffer.alloc(len);
const n = readSync(fd, b, 0, len, pos);
return b.subarray(0, n);
}
/** Entries of the central directory of a ZIP file (path) or buffer. */
export function zipEntries(src: string | Buffer): ZipEntry[] {
const fd = typeof src === 'string' ? openSync(src, 'r') : -1;
try {
const size = fd >= 0 ? fstatSync(fd).size : (src as Buffer).length;
const read = (pos: number, len: number): Buffer => (fd >= 0 ? readAt(fd, pos, len) : (src as Buffer).subarray(pos, pos + len));
const tailLen = Math.min(size, 65_557);
const tail = read(size - tailLen, tailLen);
let eocd = -1;
for (let i = tail.length - 22; i >= 0; i--) if (tail.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
if (eocd < 0) throw new Error('not a ZIP archive (no end of central directory)');
const count = tail.readUInt16LE(eocd + 10), cdSize = tail.readUInt32LE(eocd + 12), cdOff = tail.readUInt32LE(eocd + 16);
if (cdOff === 0xffffffff) throw new Error('ZIP64 archives are not supported');
const cd = read(cdOff, cdSize);
const out: ZipEntry[] = [];
for (let p = 0, k = 0; k < count && p + 46 <= cd.length; k++) {
if (cd.readUInt32LE(p) !== 0x02014b50) throw new Error('ZIP: bad central directory entry');
const nameLen = cd.readUInt16LE(p + 28), extraLen = cd.readUInt16LE(p + 30), commentLen = cd.readUInt16LE(p + 32);
out.push({ name: cd.subarray(p + 46, p + 46 + nameLen).toString('utf8'), method: cd.readUInt16LE(p + 10),
compressedSize: cd.readUInt32LE(p + 20), size: cd.readUInt32LE(p + 24), localOffset: cd.readUInt32LE(p + 42) });
p += 46 + nameLen + extraLen + commentLen;
}
return out;
} finally { if (fd >= 0) closeSync(fd); }
}
/** The uncompressed bytes of one entry. */
export function zipRead(src: string | Buffer, e: ZipEntry): Buffer {
const fd = typeof src === 'string' ? openSync(src, 'r') : -1;
try {
const read = (pos: number, len: number): Buffer => (fd >= 0 ? readAt(fd, pos, len) : (src as Buffer).subarray(pos, pos + len));
const h = read(e.localOffset, 30);
if (h.readUInt32LE(0) !== 0x04034b50) throw new Error(`ZIP: bad local header for ${e.name}`);
const data = read(e.localOffset + 30 + h.readUInt16LE(26) + h.readUInt16LE(28), e.compressedSize);
if (e.method === 0) return Buffer.from(data);
if (e.method === 8) return inflateRawSync(data);
throw new Error(`ZIP: compression method ${e.method} not supported (${e.name})`);
} finally { if (fd >= 0) closeSync(fd); }
}
/** Every file entry with its uncompressed bytes, from an in-memory archive (a Play report export). */
export function unzip(buf: Uint8Array): { name: string; data: Uint8Array }[] {
const b = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
return zipEntries(b).filter(e => !e.name.endsWith('/')).map(e => ({ name: e.name, data: new Uint8Array(zipRead(b, e)) }));
}