mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
35d6d5bb0a
* fix(artifacts): admit ZIP archives after unclaimed leading bytes ZIP recognition required the archive at byte 0, so a ZIP behind a script or self-extractor stub was refused as an unsupported binary format although standard readers accept it. Target resolution and root inventory now locate a trailing single-disk ZIP from its end record, central directory and first local header, whether member offsets include the leading bytes or not. Leading bytes any executable header family claims keep their executable route. Fixes #5732 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(artifacts): share the root ZIP family across recognition paths The trailing-ZIP fallback named a stub-prefixed root `.app` archive `zip`, while root inventory and the leading-signature route name it `app-pack`. The root mapping now lives once in `zipRootPackageFormat` and both target resolution routes and root inventory use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
import { lstat, mkdir, symlink, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import {
|
|
TextReader,
|
|
Uint8ArrayReader,
|
|
Uint8ArrayWriter,
|
|
ZipWriter,
|
|
} from "@zip.js/zip.js";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
classifyAndHashRoot,
|
|
inventoryArtifact,
|
|
} from "../../fixtures/artifactInventory.js";
|
|
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
|
import { artifactOccurrenceAt } from "../../fixtures/artifactEntryOrder.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { asarHeaderBytes } from "../../fixtures/asarHeader.js";
|
|
|
|
describe("ZIP signature detection", () => {
|
|
it.each([
|
|
{ signature: [0x50, 0x4b, 0x05, 0x06], archive: true },
|
|
{ signature: [0x50, 0x4b, 0x03, 0x06], archive: false },
|
|
{ signature: [0x50, 0x4b, 0x03, 0x04], archive: true },
|
|
{ signature: [0x50, 0x4b, 0x07, 0x08], archive: true },
|
|
] as const)(
|
|
"routes representative ZIP signature $signature through inventory and target parsing",
|
|
async ({ signature, archive }) => {
|
|
const root = await createTestTempDirectory("rea-zip-signature-");
|
|
const path = join(root, "input");
|
|
await writeFile(path, Buffer.from(signature));
|
|
expect(
|
|
(await classifyAndHashRoot(path, false, await lstat(path))).format,
|
|
).toBe(archive ? "zip" : "file");
|
|
const target = await parseBinaryTarget(path);
|
|
if (archive)
|
|
expect(target).toMatchObject({ ok: true, value: { format: "zip" } });
|
|
else
|
|
expect(target).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BinaryTargetError" },
|
|
});
|
|
},
|
|
);
|
|
|
|
it("uses ZIP magic before the filename suffix", async () => {
|
|
const root = await createTestTempDirectory("rea-zip-suffix-");
|
|
const apk = join(root, "renamed.apk");
|
|
const program = join(root, "program.apk");
|
|
await writeFile(apk, Buffer.from([0x50, 0x4b, 0x03, 0x04]));
|
|
await writeFile(program, Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
|
|
expect(
|
|
(await classifyAndHashRoot(apk, false, await lstat(apk))).format,
|
|
).toBe("apk");
|
|
expect(
|
|
(await classifyAndHashRoot(program, false, await lstat(program))).format,
|
|
).toBe("elf");
|
|
});
|
|
|
|
it("requires container magic for asar, pkg, and dmg suffixes", async () => {
|
|
const root = await createTestTempDirectory("rea-container-magic-");
|
|
const asar = join(root, "app.asar");
|
|
const textAsar = join(root, "notes.asar");
|
|
const pkg = join(root, "install.pkg");
|
|
const textPkg = join(root, "notes.pkg");
|
|
const dmg = join(root, "image.dmg");
|
|
const textDmg = join(root, "notes.dmg");
|
|
const asarBytes = asarHeaderBytes(JSON.stringify({ files: {} }));
|
|
await writeFile(asar, asarBytes);
|
|
await writeFile(textAsar, Buffer.from("not an asar"));
|
|
await writeFile(pkg, Buffer.from("xar!"));
|
|
await writeFile(textPkg, Buffer.from("not a pkg"));
|
|
const image = Buffer.alloc(512);
|
|
image.write("koly", 0, "ascii");
|
|
await writeFile(dmg, image);
|
|
await writeFile(textDmg, Buffer.from("not a dmg"));
|
|
expect(
|
|
(await classifyAndHashRoot(asar, false, await lstat(asar))).format,
|
|
).toBe("asar");
|
|
expect(
|
|
(await classifyAndHashRoot(textAsar, false, await lstat(textAsar)))
|
|
.format,
|
|
).toBe("file");
|
|
// A valid prefix cannot admit extents outside the observed container.
|
|
await writeFile(asar, asarBytes.subarray(0, -1));
|
|
expect(
|
|
(await classifyAndHashRoot(asar, false, await lstat(asar))).format,
|
|
).toBe("file");
|
|
expect(
|
|
(await classifyAndHashRoot(pkg, false, await lstat(pkg))).format,
|
|
).toBe("pkg");
|
|
expect(
|
|
(await classifyAndHashRoot(textPkg, false, await lstat(textPkg))).format,
|
|
).toBe("file");
|
|
expect(
|
|
(await classifyAndHashRoot(dmg, false, await lstat(dmg))).format,
|
|
).toBe("dmg");
|
|
expect(
|
|
(await classifyAndHashRoot(textDmg, false, await lstat(textDmg))).format,
|
|
).toBe("file");
|
|
});
|
|
|
|
it.each([[], [0x50], [0x50, 0x4b], [0x50, 0x4b, 0x03]])(
|
|
"rejects an incomplete signature %j",
|
|
async (...bytes) => {
|
|
const root = await createTestTempDirectory("rea-zip-short-");
|
|
const path = join(root, "input");
|
|
await writeFile(path, Buffer.from(bytes));
|
|
expect(
|
|
(await classifyAndHashRoot(path, false, await lstat(path))).format,
|
|
).toBe("file");
|
|
expect((await parseBinaryTarget(path)).ok).toBe(false);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("nested container family", () => {
|
|
const plainZip = async (): Promise<Uint8Array> => {
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("readme.txt", new TextReader("hello\n"));
|
|
return writer.close();
|
|
};
|
|
|
|
it("takes ZIP families from member bytes, not member suffixes", async () => {
|
|
const root = await createTestTempDirectory("rea-nested-zip-family-");
|
|
const path = join(root, "outer.zip");
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("inner-text.apk", new TextReader("just some text\n"));
|
|
await writer.add("inner-real.apk", new Uint8ArrayReader(await plainZip()));
|
|
await writer.add("renamed.bin", new Uint8ArrayReader(await plainZip()));
|
|
await writer.add("locked.ipa", new TextReader("encrypted bytes"), {
|
|
password: "fixture-only",
|
|
});
|
|
await writeFile(path, await writer.close());
|
|
const observed = await inventoryArtifact(path);
|
|
expect(artifactOccurrenceAt(observed, "inner-text.apk")).toMatchObject({
|
|
artifact_kind: "resource",
|
|
artifact_format: "file",
|
|
limitations: [],
|
|
});
|
|
expect(artifactOccurrenceAt(observed, "inner-real.apk")).toMatchObject({
|
|
artifact_kind: "container",
|
|
artifact_format: "apk",
|
|
});
|
|
expect(artifactOccurrenceAt(observed, "renamed.bin")).toMatchObject({
|
|
artifact_kind: "container",
|
|
artifact_format: "zip",
|
|
});
|
|
expect(artifactOccurrenceAt(observed, "locked.ipa")).toMatchObject({
|
|
artifact_kind: "unknown",
|
|
artifact_format: "unknown",
|
|
hash_status: "unavailable",
|
|
limitations: [
|
|
"The path suffix suggests IPA, but these bytes were not read, so the container family is unknown.",
|
|
],
|
|
});
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"does not name a container family for a symlink it did not follow",
|
|
async () => {
|
|
const root = await createTestTempDirectory("rea-nested-symlink-family-");
|
|
const directory = join(root, "application");
|
|
await mkdir(directory);
|
|
await writeFile(join(directory, "real.apk"), await plainZip());
|
|
await symlink("real.apk", join(directory, "link.apk"));
|
|
const observed = await inventoryArtifact(directory);
|
|
expect(artifactOccurrenceAt(observed, "link.apk")).toMatchObject({
|
|
entry_kind: "symlink",
|
|
artifact_kind: "unknown",
|
|
artifact_format: "unknown",
|
|
hash_status: "not-hashed",
|
|
limitations: [
|
|
"Symlink target was not followed or disclosed.",
|
|
"The path suffix suggests APK, but these bytes were not read, so the container family is unknown.",
|
|
],
|
|
});
|
|
expect(artifactOccurrenceAt(observed, "real.apk")).toMatchObject({
|
|
artifact_kind: "container",
|
|
artifact_format: "apk",
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
const SCRIPT_STUB = Buffer.from("#!/bin/sh\nexit 0\n");
|
|
|
|
const memberZip = async (): Promise<Buffer> => {
|
|
const writer = new ZipWriter(new Uint8ArrayWriter());
|
|
await writer.add("a.txt", new TextReader("hello world\n"));
|
|
await writer.add("d/b.txt", new TextReader("x\n"));
|
|
return Buffer.from(await writer.close());
|
|
};
|
|
|
|
/** Prepend bytes; optionally shift stored offsets as `zip -A` does. */
|
|
const withLeadingBytes = (
|
|
zip: Buffer,
|
|
leading: Buffer,
|
|
adjustOffsets: boolean,
|
|
): Buffer => {
|
|
const bytes = Buffer.concat([leading, zip]);
|
|
if (!adjustOffsets) return bytes;
|
|
const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
|
const directory = bytes.readUInt32LE(end + 16) + leading.length;
|
|
bytes.writeUInt32LE(directory, end + 16);
|
|
for (let at = directory; at < end;) {
|
|
bytes.writeUInt32LE(bytes.readUInt32LE(at + 42) + leading.length, at + 42);
|
|
at +=
|
|
46 +
|
|
bytes.readUInt16LE(at + 28) +
|
|
bytes.readUInt16LE(at + 30) +
|
|
bytes.readUInt16LE(at + 32);
|
|
}
|
|
return bytes;
|
|
};
|
|
|
|
/** Members by content-derived identity and verification state. */
|
|
const members = async (path: string) => {
|
|
const inventory = await inventoryArtifact(path);
|
|
return ["a.txt", "d/b.txt"].map((member) => {
|
|
const occurrence = artifactOccurrenceAt(inventory, member);
|
|
return [member, occurrence.hash_status, occurrence.artifact_id];
|
|
});
|
|
};
|
|
|
|
describe("ZIP archives after leading bytes", () => {
|
|
it.each([
|
|
{ style: "offsets that include the leading bytes", adjust: true },
|
|
{ style: "offsets written before the bytes were prepended", adjust: false },
|
|
])(
|
|
"admits a ZIP after an unclaimed script stub with $style",
|
|
async ({ adjust }) => {
|
|
const root = await createTestTempDirectory("rea-zip-leading-");
|
|
const plain = join(root, "plain.zip");
|
|
const prefixed = join(root, "installer.bin");
|
|
const zip = await memberZip();
|
|
await writeFile(plain, zip);
|
|
await writeFile(prefixed, withLeadingBytes(zip, SCRIPT_STUB, adjust));
|
|
|
|
expect(
|
|
(await classifyAndHashRoot(prefixed, false, await lstat(prefixed)))
|
|
.format,
|
|
).toBe("zip");
|
|
expect(await parseBinaryTarget(prefixed)).toMatchObject({
|
|
ok: true,
|
|
value: { kind: "archive", format: "zip" },
|
|
});
|
|
// Members are the same verified bytes as in the plain archive.
|
|
const expected = await members(plain);
|
|
expect(expected.map(([, status]) => status)).toEqual([
|
|
"verified",
|
|
"verified",
|
|
]);
|
|
expect(await members(prefixed)).toEqual(expected);
|
|
},
|
|
);
|
|
|
|
it("keeps the root App Pack family for a ZIP after a script stub", async () => {
|
|
const root = await createTestTempDirectory("rea-zip-leading-app-pack-");
|
|
const path = join(root, "Example.app");
|
|
await writeFile(
|
|
path,
|
|
withLeadingBytes(await memberZip(), SCRIPT_STUB, false),
|
|
);
|
|
expect(
|
|
(await classifyAndHashRoot(path, false, await lstat(path))).format,
|
|
).toBe("app-pack");
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: true,
|
|
value: { kind: "archive", format: "app-pack" },
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ family: "ELF", leading: Buffer.from([0x7f, 0x45, 0x4c, 0x46, 1, 1, 1]) },
|
|
{
|
|
family: "MZ",
|
|
leading: Buffer.concat([Buffer.from("MZ"), Buffer.alloc(62)]),
|
|
},
|
|
{
|
|
family: "Mach-O",
|
|
leading: Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0, 0, 0, 0]),
|
|
},
|
|
])(
|
|
"keeps a $family-led file with an appended ZIP out of the ZIP route",
|
|
async ({ leading }) => {
|
|
const root = await createTestTempDirectory("rea-zip-claimed-lead-");
|
|
const path = join(root, "program");
|
|
await writeFile(path, withLeadingBytes(await memberZip(), leading, true));
|
|
expect(
|
|
(await classifyAndHashRoot(path, false, await lstat(path))).format,
|
|
).not.toBe("zip");
|
|
expect(await parseBinaryTarget(path)).not.toMatchObject({
|
|
ok: true,
|
|
value: { format: "zip" },
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
shape: "bytes after the end record",
|
|
mutate: (bytes: Buffer) => Buffer.concat([bytes, Buffer.from("junk")]),
|
|
},
|
|
{
|
|
shape: "a corrupt central directory header",
|
|
mutate: (bytes: Buffer) => {
|
|
bytes.write("XXXX", bytes.indexOf(Buffer.from([0x50, 0x4b, 1, 2])));
|
|
return bytes;
|
|
},
|
|
},
|
|
{
|
|
shape: "a Zip64 directory offset",
|
|
mutate: (bytes: Buffer) => {
|
|
const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 5, 6]));
|
|
bytes.writeUInt32LE(0xffffffff, end + 16);
|
|
return bytes;
|
|
},
|
|
},
|
|
{
|
|
shape: "an empty archive record",
|
|
mutate: (bytes: Buffer) => {
|
|
const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 5, 6]));
|
|
bytes.writeUInt16LE(0, end + 8);
|
|
bytes.writeUInt16LE(0, end + 10);
|
|
return bytes;
|
|
},
|
|
},
|
|
])("does not locate a trailing ZIP with $shape", async ({ mutate }) => {
|
|
const root = await createTestTempDirectory("rea-zip-leading-negative-");
|
|
const path = join(root, "input.bin");
|
|
await writeFile(
|
|
path,
|
|
mutate(withLeadingBytes(await memberZip(), SCRIPT_STUB, true)),
|
|
);
|
|
expect(
|
|
(await classifyAndHashRoot(path, false, await lstat(path))).format,
|
|
).toBe("file");
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BinaryTargetError" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("package signature byte identity", () => {
|
|
it.each([
|
|
{ extension: "pkg", signature: "xar!", offset: 0, size: 4 },
|
|
{ extension: "dmg", signature: "koly", offset: 0, size: 512 },
|
|
])(
|
|
"rejects high-bit aliases of $extension magic at both admission boundaries",
|
|
async ({ extension, signature, offset, size }) => {
|
|
const root = await createTestTempDirectory("rea-package-magic-");
|
|
const path = join(root, `input.${extension}`);
|
|
const bytes = Buffer.alloc(size);
|
|
bytes.write(signature, offset, "ascii");
|
|
await writeFile(path, bytes);
|
|
expect(
|
|
(await classifyAndHashRoot(path, false, await lstat(path))).format,
|
|
).toBe(extension);
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: true,
|
|
value: { format: extension },
|
|
});
|
|
for (let index = 0; index < signature.length; index += 1) {
|
|
const changed = Buffer.from(bytes);
|
|
changed[offset + index] = changed[offset + index]! | 0x80;
|
|
await writeFile(path, changed);
|
|
expect(
|
|
(await classifyAndHashRoot(path, false, await lstat(path))).format,
|
|
).toBe("file");
|
|
expect(await parseBinaryTarget(path)).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "BinaryTargetError" },
|
|
});
|
|
}
|
|
},
|
|
);
|
|
});
|