diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index 749b6e0b57..bdff4f9274 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -13,7 +13,7 @@ import type { } from "../../domain/artifactGraph.js"; import { isZipPackageFormat, - ZIP_NON_ENTRY_TAIL_LIMITATION, + ZIP_NON_ENTRY_LIMITATION, } from "../../domain/zipPackageFormat.js"; import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js"; @@ -56,7 +56,7 @@ export const inventoryCoverage = ( coverage: { scope: "container-entries", status: traversal.status }, limitations: [ ...(isZipPackageFormat(reader.format) - ? [ZIP_NON_ENTRY_TAIL_LIMITATION] + ? [ZIP_NON_ENTRY_LIMITATION] : []), ...traversal.limitations, ], diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index 5f95bdad6c..5c0112d4c3 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -20,7 +20,7 @@ import { parseEvidence, type Evidence } from "./evidence.js"; import { artifactInspectionResultSchema } from "./artifactInspection.js"; import { isZipPackageFormat, - ZIP_NON_ENTRY_TAIL_LIMITATION, + isZipInventoryScopeLimitation, } from "./zipPackageFormat.js"; /** Complete or partially assembled inventory used by comparison workflows. */ @@ -279,7 +279,7 @@ const legacyInventoryCoverage = ( status: limitations.some( (limitation) => limitation !== "Artifact has no child container entries." && - limitation !== ZIP_NON_ENTRY_TAIL_LIMITATION && + !isZipInventoryScopeLimitation(limitation) && !( contradictionCount > 0 && limitation === diff --git a/src/domain/zipPackageFormat.ts b/src/domain/zipPackageFormat.ts index 04a73065cc..c85820ab44 100644 --- a/src/domain/zipPackageFormat.ts +++ b/src/domain/zipPackageFormat.ts @@ -52,6 +52,12 @@ export const zipPackageFormatForPath = ( return undefined; }; -/** Inventory cannot see bytes that are not central-directory entries. */ -export const ZIP_NON_ENTRY_TAIL_LIMITATION = - "ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences."; +/** Inventory lists entries, not all bytes surrounding the central directory. */ +export const ZIP_NON_ENTRY_LIMITATION = + "ZIP inventory lists central-directory entries only. Non-entry bytes, including an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences."; + +/** Recognize the same inventory scope in current and previously retained Evidence. */ +export const isZipInventoryScopeLimitation = (limitation: string): boolean => + limitation === ZIP_NON_ENTRY_LIMITATION || + limitation === + "ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences."; diff --git a/tests/boundary/filesystem/artifactComparison.test.ts b/tests/boundary/filesystem/artifactComparison.test.ts index f298c28dd8..98f136e946 100644 --- a/tests/boundary/filesystem/artifactComparison.test.ts +++ b/tests/boundary/filesystem/artifactComparison.test.ts @@ -2,6 +2,7 @@ import { chmod, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; import { fragmentInventoryEvidence } from "../../fixtures/artifactEvidence.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -553,3 +554,90 @@ describe("artifact comparison completeness", () => { ); }); }); + +describe("ZIP non-entry coverage", () => { + it("retains pre-directory APK block changes and compares historical scope descriptions", async () => { + const root = await createTestTempDirectory("rea-apk-block-compare-"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("classes.dex", new TextReader("unchanged entry")); + const zip = Buffer.from(await writer.close()); + const eocd = zip.length - 22; + expect(zip.readUInt32LE(eocd)).toBe(0x06054b50); + const directory = zip.readUInt32LE(eocd + 16); + // APK Signing Block framing with one opaque padding pair, not a verified signature. + // https://source.android.com/docs/security/features/apksigning/v2#apk-signing-block + const block = Buffer.alloc(48); + block.writeBigUInt64LE(40n, 0); + block.writeBigUInt64LE(8n, 8); + block.writeUInt32LE(0x42726577, 16); + block.writeBigUInt64LE(40n, 24); + block.write("APK Sig Block 42", 32, "ascii"); + const paths = [join(root, "before.apk"), join(root, "after.apk")]; + for (const [index, path] of paths.entries()) { + block.writeUInt32LE(index, 20); + const apk = Buffer.concat([ + zip.subarray(0, directory), + block, + zip.subarray(directory), + ]); + apk.writeUInt32LE(directory + block.length, eocd + block.length + 16); + await writeFile(path, apk); + } + const left = await observe(paths[0]!); + const right = await observe(paths[1]!); + const inventory = artifactInventoryResultSchema.parse( + left.normalized_result, + ); + expect(inventory.limitations).toContain( + "ZIP inventory lists central-directory entries only. Non-entry bytes, including an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences.", + ); + expect( + inventory.occurrences.map(({ logical_path }) => logical_path).sort(), + ).toEqual([".", "classes.dex"]); + expect(compareArtifacts(left, right)).toMatchObject({ + status: "changed", + summary: { changed: 1, unchanged: 1, unknown: 0 }, + changes: [ + expect.objectContaining({ logical_path: ".", dimensions: ["content"] }), + ], + }); + const { + coverage: _coverage, + coverage_status: _coverageStatus, + ...legacyInventory + } = inventory; + const withLimitation = (limitation: string) => + createEvidence( + { + path: paths[0]!, + sha256: inventory.manifest.root_sha256, + format: "apk", + }, + PROVIDER, + { + operation: "inventory_artifact", + parameters: {}, + result: jsonValueSchema.parse({ + ...legacyInventory, + limitations: [limitation], + }), + confidence: "observed", + authority: "shipped-artifact", + }, + ); + const historical = withLimitation( + "ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences.", + ); + expect(compareArtifacts(historical, left)).toMatchObject({ + status: "unchanged", + summary: { unchanged: 2, unknown: 0 }, + changes: [], + }); + expect( + compareArtifacts( + withLimitation("ZIP entry bytes could not be read."), + left, + ).status, + ).toBe("unknown"); + }); +}); diff --git a/tests/boundary/filesystem/harmony/application.test.ts b/tests/boundary/filesystem/harmony/application.test.ts index a50c440e9e..f889aaed87 100644 --- a/tests/boundary/filesystem/harmony/application.test.ts +++ b/tests/boundary/filesystem/harmony/application.test.ts @@ -20,7 +20,7 @@ import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGr import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ZIP_PACKAGE_FORMATS, - ZIP_NON_ENTRY_TAIL_LIMITATION, + ZIP_NON_ENTRY_LIMITATION, } from "../../../../src/domain/zipPackageFormat.js"; const { runProviderAnalysis } = createDirectAnalysis({}); @@ -264,7 +264,7 @@ describe("HarmonyOS inventory coverage", () => { artifact_kind: "container", artifact_format: format, }); - expect(inventory.limitations).toContain(ZIP_NON_ENTRY_TAIL_LIMITATION); + expect(inventory.limitations).toContain(ZIP_NON_ENTRY_LIMITATION); }, );