mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 18:52:48 +00:00
fix(apk): validate classic ZIP disk declarations
Squashed from #7471 and rebased onto main. Fixes #7433 Supersedes #7471 Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
1 parent
14de30ff97
commit
5e1ddec2da
5 files changed
+160
-10
No files matched your search
@@ -264,6 +264,13 @@ file SHA-256 and byte count. These facts describe the acquired file; they do not
|
||||
establish any decoded signing facts. Failed cleanup remains visible until the
|
||||
provider releases the descriptor on a cleanup retry.
|
||||
|
||||
A classic EOCD's disk declarations are checked before using its directory offset
|
||||
as a span in the selected file. Nonzero current or central-directory disk numbers
|
||||
are unsupported; differing per-disk and total entry counts in a single-disk
|
||||
archive are malformed. This also applies to exactly 65,535 classic entries when
|
||||
no Zip64 locator is present. A refused layout does not establish that signing
|
||||
schemes are absent; select a complete single-disk archive for inspection.
|
||||
|
||||
A file that is not a ZIP is refused as an unsupported target. A damaged ZIP
|
||||
(local headers but no end-of-central-directory), a central directory that
|
||||
cannot be walked or whose declared record count does not account for all of
|
||||
@@ -275,7 +282,7 @@ input errors, not evidence of an unsupported format. Coherent multi-disk
|
||||
locator metadata and 64-bit offsets/counts/sizes beyond the reader's safe
|
||||
integer range remain unsupported. The locator offset is disk-relative, so
|
||||
multi-disk metadata is checked before applying local file bounds. This follows
|
||||
the record fields in [PKWARE APPNOTE 6.3.10, sections 4.3.14–4.3.15](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT);
|
||||
the record fields in [PKWARE APPNOTE 6.3.10, sections 4.3.14–4.3.16 and 4.4.19–4.4.22](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT);
|
||||
it does not establish full validation of Zip64 versions or extensible data.
|
||||
Signer framing includes signature records, the public-key field and matching
|
||||
signed/outer v3 SDK ranges. Suffix bytes beyond known
|
||||
|
||||
@@ -54,6 +54,13 @@ export const mapApkBlockError = (
|
||||
path,
|
||||
`This archive uses a Zip64 structure this reader does not decode (${error.detail}).`,
|
||||
);
|
||||
case "multi-disk-unsupported":
|
||||
return new AnalysisUnsupportedTargetError(
|
||||
OPERATION,
|
||||
path,
|
||||
`The selected archive declares an unsupported multi-disk ZIP layout (${error.detail}).`,
|
||||
{ remediationAction: "Select a complete single-disk APK/ZIP archive." },
|
||||
);
|
||||
case "malformed-zip":
|
||||
return inputError(
|
||||
`Damaged ZIP/APK at byte ${String(error.offset)}: ${error.detail}.`,
|
||||
|
||||
@@ -360,3 +360,62 @@ describe("EOCD that cannot describe the archive", () => {
|
||||
expect(tail.error.kind).toBe("malformed-zip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classic ZIP disk declarations", () => {
|
||||
it.each([
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
])(
|
||||
"refuses disk %s and directory disk %s before selecting a local span",
|
||||
(disk, directoryDisk) => {
|
||||
const bytes = apkWithBlock([]);
|
||||
const eocd = bytes.length - 22;
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint16(eocd + 4, disk, true);
|
||||
view.setUint16(eocd + 6, directoryDisk, true);
|
||||
expect(parseZipTail(bytes)).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "multi-disk-unsupported" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, 2])(
|
||||
"refuses a single-disk record declaring %s of its one entry",
|
||||
(entriesOnDisk) => {
|
||||
const bytes = apkWithBlock([]);
|
||||
const eocd = bytes.length - 22;
|
||||
new DataView(bytes.buffer).setUint16(eocd + 8, entriesOnDisk, true);
|
||||
expect(parseZipTail(bytes)).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "malformed-zip", offset: eocd + 8 },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("checks disk ownership in the count-saturated classic fallback", () => {
|
||||
const bytes = apkWithBlock([]);
|
||||
const eocd = bytes.length - 22;
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint16(eocd + 4, 1, true);
|
||||
view.setUint16(eocd + 8, 0xffff, true);
|
||||
view.setUint16(eocd + 10, 0xffff, true);
|
||||
expect(parseZipTail(bytes)).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "multi-disk-unsupported" },
|
||||
});
|
||||
});
|
||||
|
||||
it("checks per-disk counts in the count-saturated classic fallback", () => {
|
||||
const bytes = apkWithBlock([]);
|
||||
const eocd = bytes.length - 22;
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint16(eocd + 8, 0xfffe, true);
|
||||
view.setUint16(eocd + 10, 0xffff, true);
|
||||
expect(parseZipTail(bytes)).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "malformed-zip", offset: eocd + 8 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ export const APK_BLOCK_ID = {
|
||||
export type ApkSigningBlockError =
|
||||
| { readonly kind: "not-a-zip"; readonly detail: string }
|
||||
| { readonly kind: "zip64-unsupported"; readonly detail: string }
|
||||
| { readonly kind: "multi-disk-unsupported"; readonly detail: string }
|
||||
| {
|
||||
/** A ZIP whose end-of-central-directory is damaged or inconsistent. */
|
||||
readonly kind: "malformed-zip";
|
||||
@@ -101,14 +102,41 @@ const hasLocalFileHeader = (bytes: Uint8Array): boolean =>
|
||||
bytes[2] === 0x03 &&
|
||||
bytes[3] === 0x04;
|
||||
|
||||
interface CentralDirectory {
|
||||
readonly offset: number;
|
||||
readonly size: number;
|
||||
readonly entries: number;
|
||||
readonly end: number;
|
||||
}
|
||||
|
||||
/** Admit a classic directory only when its offset describes this selected disk. */
|
||||
const classicDirectory = (
|
||||
eocd: ZipEndOfCentralDirectory,
|
||||
): Result<CentralDirectory, ApkSigningBlockError> => {
|
||||
if (eocd.disk !== 0 || eocd.directoryDisk !== 0)
|
||||
return err({
|
||||
kind: "multi-disk-unsupported",
|
||||
detail: `classic EOCD names disk ${String(eocd.disk)} and central-directory disk ${String(eocd.directoryDisk)}`,
|
||||
});
|
||||
if (eocd.entriesOnDisk !== eocd.totalEntries)
|
||||
return err({
|
||||
kind: "malformed-zip",
|
||||
offset: eocd.offset + 8,
|
||||
detail: "single-disk ZIP entry counts disagree",
|
||||
});
|
||||
return ok({
|
||||
offset: eocd.directoryOffset,
|
||||
size: eocd.directorySize,
|
||||
entries: eocd.totalEntries,
|
||||
end: eocd.offset,
|
||||
});
|
||||
};
|
||||
|
||||
/** Resolve the central-directory offset, following a Zip64 EOCD when present. */
|
||||
const centralDirectoryOffset = (
|
||||
bytes: Uint8Array,
|
||||
eocd: ZipEndOfCentralDirectory,
|
||||
): Result<
|
||||
{ offset: number; size: number; entries: number; end: number },
|
||||
ApkSigningBlockError
|
||||
> => {
|
||||
): Result<CentralDirectory, ApkSigningBlockError> => {
|
||||
const dv = view(bytes);
|
||||
const {
|
||||
offset: eocdOffset,
|
||||
@@ -120,7 +148,7 @@ const centralDirectoryOffset = (
|
||||
offset === ZIP64_SENTINEL ||
|
||||
size === ZIP64_SENTINEL ||
|
||||
entries === ZIP64_ENTRIES_SENTINEL;
|
||||
if (!saturated) return ok({ offset, size, entries, end: eocdOffset });
|
||||
if (!saturated) return classicDirectory(eocd);
|
||||
// Zip64: a locator precedes the EOCD, pointing at the Zip64 EOCD record.
|
||||
const locator = eocdOffset - 20;
|
||||
if (
|
||||
@@ -130,7 +158,7 @@ const centralDirectoryOffset = (
|
||||
// Exactly 65,535 entries saturates the count without Zip64; that archive
|
||||
// is still described completely by its 32-bit EOCD fields.
|
||||
if (offset !== ZIP64_SENTINEL && size !== ZIP64_SENTINEL)
|
||||
return ok({ offset, size, entries, end: eocdOffset });
|
||||
return classicDirectory(eocd);
|
||||
return err({
|
||||
kind: "malformed-zip",
|
||||
offset: eocdOffset,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.
|
||||
import { apkSignatureResultSchema } from "../../src/domain/apkSignature/apkSignatureReport.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { zip64Tail } from "../fixtures/apkSignature/zip64Tail.js";
|
||||
import { zipWith } from "../fixtures/apkSignature/v1SignatureBlock.js";
|
||||
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest, type TestCli } from "../support/cli/cliFixture.js";
|
||||
@@ -29,7 +30,7 @@ const inspectBoth = async (cli: TestCli, path: string) => {
|
||||
return { output, response };
|
||||
};
|
||||
|
||||
const expectUnsignedZip64 = async (
|
||||
const expectUnsignedArchive = async (
|
||||
cli: TestCli,
|
||||
path: string,
|
||||
bytes: Uint8Array,
|
||||
@@ -72,6 +73,8 @@ const expectRefusal = async (
|
||||
expect(output.exitCode, output.stderr).toBe(1);
|
||||
const cliError = analysisErrorProjectionSchema.parse(output.json);
|
||||
expect(cliError).toMatchObject(expected);
|
||||
expect(output.json).not.toHaveProperty("normalized_result");
|
||||
expect(response).not.toHaveProperty("structuredContent.normalized_result");
|
||||
const mcpError = parseMcpToolError(response).error;
|
||||
expect(mcpError).toMatchObject(expected);
|
||||
expect(mcpError.message).toBe(cliError.message);
|
||||
@@ -85,7 +88,7 @@ cliTest(
|
||||
const path = join(root, "selected.apk");
|
||||
const fixture = await zip64Tail();
|
||||
const { recordOffset, locatorOffset, eocdOffset } = fixture;
|
||||
await expectUnsignedZip64(cli, path, fixture.bytes);
|
||||
await expectUnsignedArchive(cli, path, fixture.bytes);
|
||||
const cases = [
|
||||
{
|
||||
at: locatorOffset,
|
||||
@@ -153,7 +156,53 @@ cliTest(
|
||||
});
|
||||
}
|
||||
// Restore the exact original bytes at the same caller-selected path.
|
||||
await expectUnsignedZip64(cli, path, fixture.bytes);
|
||||
await expectUnsignedArchive(cli, path, fixture.bytes);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"refuses classic disk declarations before returning signing Evidence through CLI and MCP",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-apk-classic-disks-");
|
||||
const path = join(root, "selected.apk");
|
||||
const original = await zipWith(
|
||||
[
|
||||
{ name: "classes.dex", data: "inert", stored: true },
|
||||
{ name: "AndroidManifest.xml", data: "<manifest/>", stored: true },
|
||||
],
|
||||
{ dataDescriptor: false },
|
||||
);
|
||||
await expectUnsignedArchive(cli, path, original);
|
||||
const eocd = original.length - 22;
|
||||
for (const at of [4, 6]) {
|
||||
const bytes = Uint8Array.from(original);
|
||||
new DataView(bytes.buffer).setUint16(eocd + at, 1, true);
|
||||
// Only disk declarations change; these are not genuine split APK sets.
|
||||
await expectRefusal(cli, path, bytes, {
|
||||
code: "unsupported_target",
|
||||
category: "unsupported_target",
|
||||
message: expect.stringContaining("multi-disk ZIP"),
|
||||
details: { path },
|
||||
});
|
||||
}
|
||||
for (const entriesOnDisk of [0, 3]) {
|
||||
const bytes = Uint8Array.from(original);
|
||||
new DataView(bytes.buffer).setUint16(eocd + 8, entriesOnDisk, true);
|
||||
await expectRefusal(cli, path, bytes, {
|
||||
code: "invalid_request",
|
||||
category: "invalid_input",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message: expect.stringContaining(`byte ${String(eocd + 8)}`),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
await expectUnsignedArchive(cli, path, original);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user