import { describe, expect, it } from "vitest";
import { writeFileSync, mkdtempSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { deflateRawSync } from "node:zlib";
import { pipeline } from "node:stream/promises";
import { Readable } from "node:stream";
import {
createByteCap,
extractExportXml,
readCentralDirectory,
} from "../unzip-export-xml";
/**
* Hand-build a single-entry ZIP archive with the supplied filename +
* payload. Enough to exercise the central-directory walker + extractor
* without pulling in a full zip library. `method` selects compression
* method 8 (deflate, default) or 0 (stored — bytes copied verbatim).
*/
function buildMinimalZip(
filename: string,
payload: Buffer,
method: 0 | 8 = 8,
): Buffer {
const compressed = method === 8 ? deflateRawSync(payload) : payload;
const crc32 = (() => {
// Pre-computed lookup table for CRC-32. node:zlib has crc32 in 22+
// but we keep the function inline to stay version-agnostic.
let c: number;
const table: number[] = [];
for (let n = 0; n < 256; n++) {
c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[n] = c;
}
let crc = 0 ^ -1;
for (let i = 0; i < payload.length; i++) {
crc = (crc >>> 8) ^ table[(crc ^ payload[i]) & 0xff];
}
return (crc ^ -1) >>> 0;
})();
const nameBuf = Buffer.from(filename, "utf8");
// Local file header
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4); // version
localHeader.writeUInt16LE(0, 6); // flags
localHeader.writeUInt16LE(method, 8); // method
localHeader.writeUInt16LE(0, 10); // time
localHeader.writeUInt16LE(0, 12); // date
localHeader.writeUInt32LE(crc32, 14);
localHeader.writeUInt32LE(compressed.length, 18);
localHeader.writeUInt32LE(payload.length, 22);
localHeader.writeUInt16LE(nameBuf.length, 26);
localHeader.writeUInt16LE(0, 28); // extra len
// Central directory file header
const cdh = Buffer.alloc(46);
cdh.writeUInt32LE(0x02014b50, 0);
cdh.writeUInt16LE(20, 4); // version made by
cdh.writeUInt16LE(20, 6); // version needed
cdh.writeUInt16LE(0, 8); // flags
cdh.writeUInt16LE(method, 10); // method
cdh.writeUInt16LE(0, 12); // time
cdh.writeUInt16LE(0, 14); // date
cdh.writeUInt32LE(crc32, 16);
cdh.writeUInt32LE(compressed.length, 20);
cdh.writeUInt32LE(payload.length, 24);
cdh.writeUInt16LE(nameBuf.length, 28);
cdh.writeUInt16LE(0, 30); // extra
cdh.writeUInt16LE(0, 32); // comment
cdh.writeUInt16LE(0, 34); // disk num
cdh.writeUInt16LE(0, 36); // internal attrs
cdh.writeUInt32LE(0, 38); // external attrs
cdh.writeUInt32LE(0, 42); // local header offset
// End of Central Directory
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4); // disk
eocd.writeUInt16LE(0, 6); // disk-with-cd
eocd.writeUInt16LE(1, 8); // entries-on-disk
eocd.writeUInt16LE(1, 10); // total-entries
eocd.writeUInt32LE(46 + nameBuf.length, 12); // cd size
// cd offset = local-header + name + data
eocd.writeUInt32LE(30 + nameBuf.length + compressed.length, 16);
eocd.writeUInt16LE(0, 20); // comment len
return Buffer.concat([localHeader, nameBuf, compressed, cdh, nameBuf, eocd]);
}
describe("readCentralDirectory", () => {
it("walks a single-entry archive", () => {
const payload = Buffer.from("");
const zip = buildMinimalZip("apple_health_export/export.xml", payload);
const entries = readCentralDirectory(zip);
expect(entries).toHaveLength(1);
expect(entries[0].fileName).toBe("apple_health_export/export.xml");
expect(entries[0].compressionMethod).toBe(8);
expect(entries[0].uncompressedSize).toBe(payload.length);
});
});
describe("extractExportXml", () => {
it("extracts a deflated export.xml member to a temp file", async () => {
const payload = Buffer.from(
``,
);
const zip = buildMinimalZip("apple_health_export/export.xml", payload);
const tmp = mkdtempSync(join(tmpdir(), "healthlog-unzip-"));
const zipPath = join(tmp, "export.zip");
writeFileSync(zipPath, zip);
const out = await extractExportXml(zipPath);
expect(out.xmlBytes).toBe(payload.length);
expect(out.otherMembers).toHaveLength(0);
const extracted = readFileSync(out.xmlPath);
expect(extracted.toString("utf8")).toBe(payload.toString("utf8"));
});
// v1.32.1 (issue #588) — the stored (uncompressed) path now runs
// through the same streaming pipeline as deflate; a stored member
// must still round-trip byte-exact through it.
it("extracts a stored (uncompressed) export.xml member to a temp file", async () => {
const payload = Buffer.from(
``,
);
const zip = buildMinimalZip("apple_health_export/export.xml", payload, 0);
const tmp = mkdtempSync(join(tmpdir(), "healthlog-unzip-"));
const zipPath = join(tmp, "export.zip");
writeFileSync(zipPath, zip);
const out = await extractExportXml(zipPath);
expect(out.xmlBytes).toBe(payload.length);
const extracted = readFileSync(out.xmlPath);
expect(extracted.toString("utf8")).toBe(payload.toString("utf8"));
});
it("throws when the export.xml member is missing", async () => {
const payload = Buffer.from("noop");
const zip = buildMinimalZip("other-file.txt", payload);
const tmp = mkdtempSync(join(tmpdir(), "healthlog-unzip-"));
const zipPath = join(tmp, "export.zip");
writeFileSync(zipPath, zip);
await expect(extractExportXml(zipPath)).rejects.toThrow(/missing/);
});
});
// v1.32.1 (issue #588) — the streaming inflate Transform has no
// `maxOutputLength` option, so the zip-bomb defence moved to a
// hand-rolled byte-counting stage. Exercise it directly at a tiny
// limit; the real 8 GiB ceiling is not practical to trip from a
// fixture.
describe("createByteCap", () => {
it("passes bytes through unchanged while under the limit", async () => {
const chunks: Buffer[] = [];
await pipeline(
Readable.from([Buffer.from("hello"), Buffer.from("world")]),
createByteCap(10),
async function collect(source) {
for await (const chunk of source) chunks.push(chunk as Buffer);
},
);
expect(Buffer.concat(chunks).toString("utf8")).toBe("helloworld");
});
it("rejects the stream once the running total exceeds the limit", async () => {
await expect(
pipeline(
Readable.from([Buffer.from("hello"), Buffer.from("world")]),
createByteCap(6),
async function drain(source) {
for await (const chunk of source) {
void chunk;
}
},
),
).rejects.toThrow(/exceeds the 6-byte cap/);
});
});