fix(artifacts): preserve physical ZIP member identities (#1865)

Bind each ZIP occurrence independently of its filename so engine metadata
inspection can read repeated resources without replacing retained sources.
Keep logical-path collision checks in artifact inventory.

Closes #1860

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-11 22:58:01 +08:00
1 parent 7e2f79ab45
commit bcd350c365
3 files changed
+64 -20

No files matched your search

+15 -14
View File
@@ -147,22 +147,23 @@ export class ZipArtifactReader implements ArtifactReader {
}
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
const filenames = new Set<string>();
let index = 0;
for await (const entry of this.#reader.getEntriesGenerator()) {
abortIfNeeded(signal);
// Each enumeration can replay valid entries, but a second record with
// the same name must not replace an already emitted source identity.
if (filenames.has(entry.filename))
// ZIP member identity is its directory occurrence, not its filename.
// Logical path uniqueness belongs to the artifact inventory boundary.
const metadata = projectZipEntry(entry, index++);
const stored = this.#entries.get(metadata.adapterKey);
if (stored !== undefined && !sameArtifactEntry(metadata, stored.metadata))
throw new ArtifactReaderFailure(
"path",
`Artifact path collision: ${entry.filename}`,
"integrity",
`ZIP member identity changed during enumeration: ${entry.filename}`,
);
filenames.add(entry.filename);
const metadata = projectZipEntry(entry);
this.#entries.set(entry.filename, {
source: entry,
metadata: copyArtifactEntry(metadata),
});
if (stored === undefined)
this.#entries.set(metadata.adapterKey, {
source: entry,
metadata: copyArtifactEntry(metadata),
});
yield metadata;
}
}
@@ -203,7 +204,7 @@ export class ZipArtifactReader implements ArtifactReader {
}
}
const projectZipEntry = (entry: Entry): ArtifactEntry => {
const projectZipEntry = (entry: Entry, index: number): ArtifactEntry => {
const symlink = isSymlink(entry);
return {
path: entry.filename,
@@ -216,7 +217,7 @@ const projectZipEntry = (entry: Entry): ArtifactEntry => {
declaredSha256: null,
unpacked: false,
limitations: symlink ? ["Archive symlink target was not read."] : [],
adapterKey: entry.filename,
adapterKey: `zip-entry:${index}`,
};
};
@@ -86,6 +86,40 @@ it("accepts a full JDK and canonicalizes the selected JAR", async () => {
});
});
it("admits engine classes past repeated ZIP resource filenames", async () => {
const root = await createTestTempDirectory("rea-jadx-duplicate-resource-");
const jar = join(root, "engine.jar");
const javaHome = await createFakeJdk(root);
await writeOrderedZip(jar, [
"META-INF/LICENSE.txt",
"META-INF/LICENCE.txt",
"com/atxx/jhmcp/JadxSession.class",
"com/atxx/jhmcp/SessionHolder.class",
"jadx/api/JadxDecompiler.class",
"com/google/gson/Gson.class",
"io/modelcontextprotocol/kotlin/sdk/server/Server.class",
]);
const bytes = await readFile(jar);
const original = Buffer.from("META-INF/LICENCE.txt");
let renamed = 0;
for (
let offset = bytes.indexOf(original);
offset >= 0;
offset = bytes.indexOf(original, offset + original.length)
) {
bytes.write("META-INF/LICENSE.txt", offset);
renamed++;
}
expect(renamed).toBe(2);
await writeFile(jar, bytes);
await expect(
resolveConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
"inspect_android_package",
),
).resolves.toMatchObject({ jar: await realpath(jar) });
});
it("configures heap and visible processors independently without forcing either", async () => {
const root = await createTestTempDirectory("rea-jadx-config-");
const jar = join(root, "engine.jar");
@@ -77,7 +77,7 @@ it("binds ZIP opens to the emitted entry metadata", async () => {
}
});
it("rejects duplicate ZIP names before replacing an emitted entry", async () => {
it("retains duplicate ZIP members without replacing their source identities", async () => {
const root = await createTestTempDirectory("rea-zip-duplicate-identity-");
const path = join(root, "duplicates.zip");
const writer = new ZipWriter(new Uint8ArrayWriter());
@@ -106,11 +106,20 @@ it("rejects duplicate ZIP names before replacing an emitted entry", async () =>
const iterator = reader.entries()[Symbol.asyncIterator]();
const first = await iterator.next();
if (first.done) throw new Error("Expected the first ZIP member");
await expect(iterator.next()).rejects.toMatchObject({ reason: "path" });
const stream = await reader.open({ ...first.value });
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks).toString()).toBe("first bytes\n");
const second = await iterator.next();
if (second.done) throw new Error("Expected the second ZIP member");
expect(first.value.path).toBe(second.value.path);
expect(first.value.adapterKey).not.toBe(second.value.adapterKey);
expect((await iterator.next()).done).toBe(true);
for (const [entry, expected] of [
[first.value, "first bytes\n"],
[second.value, "later bytes\n"],
] as const) {
const chunks: Buffer[] = [];
for await (const chunk of await reader.open({ ...entry }))
chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks).toString()).toBe(expected);
}
} finally {
await reader.close();
}