fix(artifacts): keep case-distinct archive names in inventory (#1480)

ZIP member names are case-sensitive. Inventory was folding them with a
filesystem case rule, so shrunk APKs never became evidence for the Android
application graph. Logical registration now keeps both spellings and records
an English Unicode case-folding limitation. Extraction fails only when the
destination directory already holds the other spelling.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
morluto 2026-10-10 15:21:16 +08:00 committed by GitHub
parent 7296f2b5bf
commit 1f0775684b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 425 additions and 95 deletions

View File

@ -126,8 +126,13 @@ also stops target hashing before provider admission.
These operations do not execute the APK or provide split APK/AAB handling,
signature validation, full resource-table semantics, native-library analysis or
Android runtime capture. Existing artifact inventory/extraction tools can supply
archive evidence. Project that Evidence with the execution-free inventory tool:
Android runtime capture. An `.aab` suffix is a ZIP archive, not an APK, so
Android tools and the application graph do not treat it as a standalone APK.
Existing artifact inventory/extraction tools can supply archive evidence.
Inventory keeps ZIP names that differ only in case and records that a
case-insensitive destination cannot store both spellings. Extraction fails
only when that destination directory cannot keep both names. Project that
Evidence with the execution-free inventory tool:
```sh
rea project-android-application-graph '{"inventory_evidence":[<inventory_artifact Evidence>]}'

View File

@ -1,9 +1,24 @@
import { posix } from "node:path";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
const DRIVE_OR_UNC = /^(?:[A-Za-z]:|\\|\/\/)/u;
/**
* English Unicode case folding used only to describe logical names a
* case-insensitive destination might collapse. It is not a filesystem case table.
*/
const ENGLISH_UNICODE_CASE_NOTE =
"This comparison is not a filesystem observation.";
/** Inventory limitation when logical names collide under English Unicode case folding. */
export const ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION = `Logical archive names collide under English (en-US) Unicode case folding; a case-insensitive destination cannot store both spellings. ${ENGLISH_UNICODE_CASE_NOTE}`;
/** User-facing prefix for a destination that cannot keep two logical spellings. */
export const DESTINATION_CASE_COLLISION_PREFIX =
"Destination filesystem cannot store both ";
/** Normalize an untrusted logical archive path without touching filesystem. */
export const normalizeArtifactPath = (input: string): string => {
if (
@ -30,7 +45,7 @@ export const normalizeArtifactPath = (input: string): string => {
return normalized;
};
/** Reject exact, Unicode-normalized, case-folded, and prefix collisions. */
/** Reject exact and same-spelling prefix collisions. Case-distinct names stay distinct. */
export class ArtifactPathRegistry {
readonly #root: PathTrieNode = { kind: undefined, children: new Map() };
@ -43,16 +58,10 @@ export class ArtifactPathRegistry {
"path",
`Artifact prefix conflict: ${path}`,
);
const foldedPart = part.toLocaleLowerCase("en-US");
let child = node.children.get(foldedPart);
if (child !== undefined && child.spelling !== part)
throw new ArtifactReaderFailure(
"path",
`Artifact path collision: ${path} differs only in case from ${[...parts.slice(0, index), child.spelling].join("/")}`,
);
let child = node.children.get(part);
if (child === undefined) {
child = { spelling: part, kind: undefined, children: new Map() };
node.children.set(foldedPart, child);
child = { kind: undefined, children: new Map() };
node.children.set(part, child);
}
node = child;
if (index === parts.length - 1) {
@ -73,7 +82,144 @@ export class ArtifactPathRegistry {
}
interface PathTrieNode {
readonly spelling?: string;
kind: "file" | "directory" | "symlink" | "slice" | undefined;
readonly children: Map<string, PathTrieNode>;
}
interface CaseNode {
readonly bySpelling: Map<string, CaseNode>;
readonly spellingsByFold: Map<string, string[]>;
readonly terminals: string[];
}
const emptyCaseNode = (): CaseNode => ({
bySpelling: new Map(),
spellingsByFold: new Map(),
terminals: [],
});
const englishCaseFold = (part: string): string =>
part.toLocaleLowerCase("en-US");
/**
* Record English (en-US) Unicode case collisions on each affected occurrence.
* Returns the inventory limitation when any logical names collide.
*/
export const annotateEnglishUnicodeCaseCollisions = (
occurrences: readonly {
readonly logical_path: string;
limitations: string[];
}[],
): string | undefined => {
const collisions = englishUnicodeCaseCollisions(
occurrences.map(({ logical_path }) => logical_path),
);
if (collisions.size === 0) return undefined;
for (const occurrence of occurrences) {
const others = collisions.get(occurrence.logical_path);
if (others === undefined || others.length === 0) continue;
occurrence.limitations.push(
`Logical path ${occurrence.logical_path} collides under English (en-US) Unicode case folding with ${others.join(", ")}; a case-insensitive destination cannot store both. ${ENGLISH_UNICODE_CASE_NOTE}`,
);
}
return ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION;
};
/**
* Name the directory-listing spelling that differs from the requested segment
* only by English (en-US) Unicode case. Exact matches are the same logical name.
*/
export const destinationCaseCollisionMessage = (
requestedPath: string,
existingNames: readonly string[],
): string | undefined => {
const slash = requestedPath.lastIndexOf("/");
const segment = slash < 0 ? requestedPath : requestedPath.slice(slash + 1);
if (existingNames.includes(segment)) return undefined;
const folded = englishCaseFold(segment);
const existing = existingNames
.filter((name) => englishCaseFold(name) === folded)
.sort(compareUnicodeCodePoints)[0];
if (existing === undefined) return undefined;
const parent = slash < 0 ? "" : requestedPath.slice(0, slash + 1);
return `${DESTINATION_CASE_COLLISION_PREFIX}${requestedPath} and ${parent}${existing}; inventory retains both logical names.`;
};
const englishUnicodeCaseCollisions = (
paths: readonly string[],
): ReadonlyMap<string, readonly string[]> => {
const root = emptyCaseNode();
for (const path of paths) {
if (path.length === 0 || path === ".") continue;
let node = root;
for (const part of path.split("/")) {
const folded = englishCaseFold(part);
const spellings = node.spellingsByFold.get(folded);
if (spellings === undefined) node.spellingsByFold.set(folded, [part]);
else if (!spellings.includes(part)) spellings.push(part);
let child = node.bySpelling.get(part);
if (child === undefined) {
child = emptyCaseNode();
node.bySpelling.set(part, child);
}
node = child;
}
node.terminals.push(path);
}
const collisions = new Map<string, Set<string>>();
recordCaseCollisions(root, collisions);
return new Map(
[...collisions.entries()].map(([path, others]) => [
path,
[...others].sort(compareUnicodeCodePoints),
]),
);
};
const recordCaseCollisions = (
node: CaseNode,
collisions: Map<string, Set<string>>,
): void => {
for (const spellings of node.spellingsByFold.values())
if (spellings.length > 1) noteFoldedSpellings(node, spellings, collisions);
for (const child of node.bySpelling.values())
recordCaseCollisions(child, collisions);
};
const noteFoldedSpellings = (
node: CaseNode,
spellings: readonly string[],
collisions: Map<string, Set<string>>,
): void => {
const groups = spellings.map((spelling) => {
const child = node.bySpelling.get(spelling);
return child === undefined ? [] : terminalsUnder(child);
});
for (const [index, group] of groups.entries())
noteCaseGroup(
group,
groups.flatMap((candidate, candidateIndex) =>
candidateIndex === index ? [] : candidate,
),
collisions,
);
};
const noteCaseGroup = (
group: readonly string[],
others: readonly string[],
collisions: Map<string, Set<string>>,
): void => {
for (const path of group) {
const set = collisions.get(path) ?? new Set<string>();
for (const other of others) if (other !== path) set.add(other);
collisions.set(path, set);
}
};
const terminalsUnder = (node: CaseNode): readonly string[] => {
const paths = [...node.terminals];
for (const child of node.bySpelling.values())
paths.push(...terminalsUnder(child));
return paths;
};

View File

@ -4,6 +4,7 @@ import {
lstat,
mkdir,
open,
readdir,
realpath,
rm,
type FileHandle,
@ -14,6 +15,7 @@ import { streamChunkToBuffer } from "./StreamBytes.js";
import {
ArtifactPathRegistry,
destinationCaseCollisionMessage,
normalizeArtifactPath,
} from "./ArtifactPaths.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
@ -134,7 +136,8 @@ export class SafeOutputTree {
constants.O_WRONLY |
constants.O_NOFOLLOW,
0o600,
).catch((cause: unknown) => {
).catch(async (cause: unknown) => {
await throwIfDestinationCaseCollision(dirname(destination), path, cause);
throw new ArtifactReaderFailure(
"path",
`Could not exclusively create extraction path: ${path}`,
@ -245,17 +248,28 @@ export class SafeOutputTree {
if (fileName === undefined)
throw new ArtifactReaderFailure("path", "Invalid extraction path");
let current = this.#outputRoot;
let logicalParent = "";
for (const part of parts) {
const parentDirectory = current;
const logicalPath =
logicalParent.length === 0 ? part : `${logicalParent}/${part}`;
current = join(current, part);
let created = true;
await mkdir(current, { mode: 0o700 }).catch((cause: unknown) => {
if (!isAlreadyExists(cause)) throw cause;
created = false;
});
if (!created)
await throwIfDestinationCaseCollision(parentDirectory, logicalPath);
const metadata = await lstat(current);
if (!metadata.isDirectory() || metadata.isSymbolicLink())
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
await throwIfDestinationCaseCollision(parentDirectory, logicalPath);
throw new ArtifactReaderFailure(
"path",
`Unsafe extraction parent: ${relativePath}`,
);
}
logicalParent = logicalPath;
}
return join(current, fileName);
}
@ -330,6 +344,23 @@ const isNotFound = (cause: unknown): boolean =>
const isAlreadyExists = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "EEXIST";
/** Fail when this directory already holds another spelling of the requested segment. */
const throwIfDestinationCaseCollision = async (
parentDirectory: string,
logicalPath: string,
cause?: unknown,
): Promise<void> => {
const names = await readdir(parentDirectory).catch(() => undefined);
if (names === undefined) return;
const message = destinationCaseCollisionMessage(logicalPath, names);
if (message === undefined) return;
throw new ArtifactReaderFailure(
"path",
message,
cause === undefined ? undefined : { cause },
);
};
const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(

View File

@ -73,12 +73,12 @@ export const scanCanonicalArtifactInventory = async (
try {
if (reader instanceof AsarArtifactReader)
await reader.prepareContainer(rootDigest?.sha256, options.signal);
const { nodes, occurrences, pendingContradictions } = await scanReader(
reader,
ownedReaders,
options.signal,
integrity,
);
const {
nodes,
occurrences,
pendingContradictions,
caseCollisionLimitation,
} = await scanReader(reader, ownedReaders, options.signal, integrity);
outcome = {
kind: "completed",
snapshot: await buildInventorySnapshot({
@ -91,6 +91,7 @@ export const scanCanonicalArtifactInventory = async (
nodes,
occurrences,
pendingContradictions,
caseCollisionLimitation,
}),
};
} catch (cause: unknown) {
@ -130,6 +131,7 @@ interface SnapshotBuildInput {
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
readonly caseCollisionLimitation: string | undefined;
}
const buildInventorySnapshot = async (
@ -202,7 +204,12 @@ const buildInventorySnapshot = async (
edges: orderedEdges,
provenance: reader?.provenance() ?? [],
integrity_contradictions: integrityContradictions,
limitations: buildLimitations(rootFormat, reader, integrityContradictions),
limitations: buildLimitations(
rootFormat,
reader,
integrityContradictions,
input.caseCollisionLimitation,
),
};
};
@ -310,8 +317,10 @@ const buildLimitations = (
rootFormat: ArtifactOccurrence["artifact_format"],
reader: ArtifactReader | undefined,
integrityContradictions: readonly IntegrityContradiction[],
caseCollisionLimitation: string | undefined,
): string[] => [
...inventoryLimitations(rootFormat, reader),
...(caseCollisionLimitation === undefined ? [] : [caseCollisionLimitation]),
...(integrityContradictions.length === 0
? []
: [

View File

@ -1,6 +1,7 @@
import { isAbsolute } from "node:path";
import {
annotateEnglishUnicodeCaseCollisions,
ArtifactPathRegistry,
normalizeArtifactPath,
} from "../ArtifactPaths.js";
@ -64,10 +65,16 @@ export const scanReader = async (
readonly nodes: Map<string, ArtifactNode>;
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
readonly caseCollisionLimitation: string | undefined;
}> => {
const { nodes, occurrences, pendingContradictions } = emptyScan();
if (reader === undefined)
return { nodes, occurrences, pendingContradictions };
return {
nodes,
occurrences,
pendingContradictions,
caseCollisionLimitation: undefined,
};
const context: ScanContext = {
reader,
signal,
@ -90,7 +97,12 @@ export const scanReader = async (
context.occurrenceByPath,
context.expandedContainerIds,
)?.occurrence_id ?? null;
return { nodes, occurrences, pendingContradictions };
return {
nodes,
occurrences,
pendingContradictions,
caseCollisionLimitation: annotateEnglishUnicodeCaseCollisions(occurrences),
};
};
const visitArtifactEntries = async (

View File

@ -191,7 +191,7 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => {
const standardMessage = standardErrorMessage(error._tag);
if (standardMessage !== undefined) return standardMessage;
if (error instanceof ArtifactOperationError)
return artifactMessage(error.operation, error.reason);
return artifactMessage(error.operation, error.reason, error.detail);
if (error instanceof EvidenceReferenceError)
return error.reason === "missing"
? `Evidence ${error.evidenceId} is not retained in this session. Supply complete inline Evidence, re-run its producer, or import its bundle before using this reference.`
@ -329,13 +329,13 @@ const START_FAILURE_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
const artifactMessage = (
operation: ArtifactOperationError["operation"],
reason: ArtifactOperationError["reason"],
detail?: string,
): string => {
if (reason === "cancelled")
return "Artifact operation was cancelled. Start it again when ready.";
if (reason === "limit")
return "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact.";
if (reason === "path")
return "Artifact contains an unsafe or conflicting internal path. Inspect the reported path and correct the artifact before retrying.";
if (reason === "path") return artifactPathMessage(detail);
if (reason === "unavailable")
return "Artifact processing is unavailable for the current target or host. Check artifact support and required tools.";
if (reason === "integrity" && operation === "analyze_javascript_application")
@ -345,6 +345,23 @@ const artifactMessage = (
return "Artifact could not be read or written. Check file access and try again.";
};
const artifactPathMessage = (detail: string | undefined): string => {
// Kept in step with DESTINATION_CASE_COLLISION_PREFIX. Domain does not import
// the artifact adapter that produces the detail.
if (
detail !== undefined &&
detail.startsWith("Destination filesystem cannot store both ")
)
return detail;
if (
detail !== undefined &&
(detail.startsWith("Artifact path is absolute or unsafe:") ||
detail.startsWith("Artifact path is not normalized:"))
)
return "Artifact contains an unsafe internal path. Inspect the reported path and correct the artifact before retrying.";
return "Artifact contains a conflicting internal path. Inspect the reported path before retrying.";
};
const evidenceFileMessage = ({
operation,
reason,

View File

@ -125,6 +125,8 @@ export const projectAndroidApplication = (
"Manifest, resource, signing, and bytecode semantics require a dedicated Android provider; this projection reports exact inventory paths and hashes only.",
"Runtime families are inferred from inventory formats and paths; filename suffixes do not establish valid DEX or JVM class bytes.",
"Bridge candidates are path-based hypotheses, not decoded JNI declarations or observed runtime calls.",
"A bridge basis is inferred from the native path and is repeated for every managed component.",
"ZIP inventory cannot see the APK Signing Block, so an empty signing array does not mean the APK is unsigned.",
...(bridgeProjection.coverage.status === "partial"
? [
`Bridge candidate pairs exceeded the projection safety budget; ${bridgeProjection.coverage.omitted_candidates} hypotheses are omitted. Component arrays still include every component from the supplied inventory pages.`,
@ -157,23 +159,12 @@ export const projectAndroidApplication = (
};
const classify = (all: readonly Component[]) => ({
manifests: all.filter(
({ path, format }) =>
format === "android-manifest" ||
/(?:^|\/)AndroidManifest\.xml$/u.test(path),
),
resources: all.filter(
({ path, format }) =>
format === "android-resources" || /(?:^|\/)resources\.arsc$/u.test(path),
),
dex: all.filter(
({ path, format }) =>
format === "dex" || /(?:^|\/)[^/]+\.dex$/iu.test(path),
),
jvm_classes: all.filter(
({ path, format }) =>
format === "jvm-class" || /(?:^|\/)[^/]+\.class$/iu.test(path),
manifests: all.filter(({ path }) =>
/(?:^|\/)AndroidManifest\.xml$/u.test(path),
),
resources: all.filter(({ path }) => /(?:^|\/)resources\.arsc$/u.test(path)),
dex: all.filter(({ path }) => /(?:^|\/)[^/]+\.dex$/iu.test(path)),
jvm_classes: all.filter(({ path }) => /(?:^|\/)[^/]+\.class$/iu.test(path)),
native_libraries: all.filter(
({ path, format }) =>
format === "elf" || /(?:^|\/)lib\/[^/]+\/[^/]+\.so$/iu.test(path),
@ -189,19 +180,9 @@ const runtimeFamilies = (all: readonly Component[]) => {
const families = new Set<
AndroidApplicationProjectionResult["runtime_families"][number]
>();
if (
all.some(
({ path, format }) =>
format === "dex" || path.toLowerCase().endsWith(".dex"),
)
)
if (all.some(({ path }) => path.toLowerCase().endsWith(".dex")))
families.add("dalvik-art");
if (
all.some(
({ path, format }) =>
format === "jvm-class" || path.toLowerCase().endsWith(".class"),
)
)
if (all.some(({ path }) => path.toLowerCase().endsWith(".class")))
families.add("java-kotlin");
if (all.some(({ format }) => format === "elf")) families.add("native");
if (all.some(({ format }) => format === "javascript-bundle"))

View File

@ -22,7 +22,8 @@ export const zipPackageFormatForPath = (
const lower = path.toLowerCase();
if (lower.endsWith(".zip")) return "zip";
if (lower.endsWith(".ipa")) return "ipa";
if (lower.endsWith(".apk") || lower.endsWith(".aab")) return "apk";
// An Android App Bundle is a ZIP archive, not a standalone APK.
if (lower.endsWith(".apk")) return "apk";
if (lower.endsWith(".msix") || lower.endsWith(".msixbundle")) return "msix";
if (lower.endsWith(".appx") || lower.endsWith(".appxbundle")) return "appx";
return undefined;

View File

@ -9,6 +9,8 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js
import { createDirectAnalysis } from "../../../../src/composition/directAnalysis.js";
import {
ArtifactPathRegistry,
destinationCaseCollisionMessage,
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
normalizeArtifactPath,
} from "../../../../src/artifacts/ArtifactPaths.js";
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
@ -95,17 +97,8 @@ describe("artifact archive safety", () => {
});
const registry = new ArtifactPathRegistry();
registry.add("A.js", "file");
let collisionError: unknown;
try {
registry.add("a.js", "file");
} catch (error: unknown) {
collisionError = error;
}
expect(collisionError).toBeInstanceOf(ArtifactReaderFailure);
expect(collisionError).toMatchObject({
reason: "path",
message: "Artifact path collision: a.js differs only in case from A.js",
});
expect(() => registry.add("a.js", "file")).not.toThrow();
expect(() => registry.add("A.js", "file")).toThrow(ArtifactReaderFailure);
for (const [filePath, childPath] of [
["Foo", "foo/bar"],
@ -113,11 +106,15 @@ describe("artifact archive safety", () => {
] as const) {
const casePrefixRegistry = new ArtifactPathRegistry();
casePrefixRegistry.add(filePath, "file");
expect(() => casePrefixRegistry.add(childPath, "file")).toThrow(
ArtifactReaderFailure,
);
expect(() => casePrefixRegistry.add(childPath, "file")).not.toThrow();
}
const exactPrefixRegistry = new ArtifactPathRegistry();
exactPrefixRegistry.add("Foo", "file");
expect(() => exactPrefixRegistry.add("Foo/bar", "file")).toThrow(
ArtifactReaderFailure,
);
const sameDirectoryRegistry = new ArtifactPathRegistry();
sameDirectoryRegistry.add("Foo/one.js", "file");
expect(() => sameDirectoryRegistry.add("Foo/two.js", "file")).not.toThrow();
@ -126,7 +123,50 @@ describe("artifact archive safety", () => {
caseVariantDirectoryRegistry.add("Foo/one.js", "file");
expect(() =>
caseVariantDirectoryRegistry.add("foo/two.js", "file"),
).toThrow(ArtifactReaderFailure);
).not.toThrow();
});
it("names a destination case collision from the directory listing", () => {
expect(
destinationCaseCollisionMessage("res/2f.xml", ["2F.xml", "other.xml"]),
).toBe(
"Destination filesystem cannot store both res/2f.xml and res/2F.xml; inventory retains both logical names.",
);
expect(
destinationCaseCollisionMessage("res/2f.xml", ["2f.xml", "2F.xml"]),
).toBeUndefined();
});
it("extracts logical names that differ only in case", async () => {
const root = await createTestTempDirectory("rea-case-distinct-zip-");
const path = join(root, "fixture.zip");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("Main.js", new TextReader("upper"));
await writer.add("main.js", new TextReader("lower"));
await writeFile(path, await writer.close());
const parsed = await parseBinaryTarget(path);
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
const result = await inventory(parsed.value);
expect(result.limitations).toContain(
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
);
expect(
result.occurrences
.filter(({ logical_path }) => logical_path !== ".")
.map(({ logical_path }) => logical_path)
.sort(),
).toEqual(["Main.js", "main.js"]);
const output = join(root, "output");
const extracted = await new ArtifactProvider(process.env)
.createClient(parsed.value)
.execute(
"extract_artifact",
artifactExtractionExecutionSchema.parse({ output_root: output }),
);
expect(extracted.ok).toBe(true);
expect(await readFile(join(output, "Main.js"), "utf8")).toBe("upper");
expect(await readFile(join(output, "main.js"), "utf8")).toBe("lower");
});
});
const inventory = async (targetValue: BinaryTarget) => {

View File

@ -6,30 +6,73 @@ import { writeOrderedZip } from "../../../fixtures/artifactEntryOrder.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
import {
destinationCaseCollisionMessage,
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
} from "../../../../src/artifacts/ArtifactPaths.js";
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js";
import { ArtifactOperationError } from "../../../../src/domain/artifactOperationError.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
it.each([
[
["Main.js", "main.js"],
"Artifact path collision: main.js differs only in case from Main.js",
],
[["a\\b.js"], 'Artifact path is absolute or unsafe: "a\\\\b.js"'],
] as const)(
"reports the failed path constraint for %j",
async (entries, detail) => {
const root = await createTestTempDirectory("rea-artifact-detail-");
const path = join(root, "fixture.zip");
await writeOrderedZip(path, entries);
const target = await parseBinaryTarget(path);
if (!target.ok) throw new Error("expected a ZIP target");
const result = await new ArtifactProvider(process.env)
.createClient(target.value)
.execute("inventory_artifact", {});
if (result.ok) throw new Error("expected the path constraint to fail");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "artifact_operation_failed",
details: { operation: "inventory_artifact", reason: "path", detail },
});
},
);
it("keeps logical names that differ only in case and records the limitation", async () => {
const root = await createTestTempDirectory("rea-artifact-detail-");
const path = join(root, "fixture.zip");
await writeOrderedZip(path, ["Main.js", "main.js"]);
const target = await parseBinaryTarget(path);
if (!target.ok) throw new Error("expected a ZIP target");
const result = await new ArtifactProvider(process.env)
.createClient(target.value)
.execute("inventory_artifact", {});
if (!result.ok) throw result.error;
const inventory = artifactInventoryResultSchema.parse(result.value.result);
expect(inventory.occurrences.map(({ logical_path }) => logical_path)).toEqual(
expect.arrayContaining(["Main.js", "main.js"]),
);
expect(inventory.limitations).toContain(
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
);
for (const logicalPath of ["Main.js", "main.js"]) {
expect(
inventory.occurrences
.find((occurrence) => occurrence.logical_path === logicalPath)
?.limitations.join("\n"),
).toContain("English (en-US) Unicode case folding");
}
});
it("reports an unsafe path as a hostile artifact path", async () => {
const root = await createTestTempDirectory("rea-artifact-detail-");
const path = join(root, "fixture.zip");
await writeOrderedZip(path, ["a\\b.js"]);
const target = await parseBinaryTarget(path);
if (!target.ok) throw new Error("expected a ZIP target");
const result = await new ArtifactProvider(process.env)
.createClient(target.value)
.execute("inventory_artifact", {});
if (result.ok) throw new Error("expected the path constraint to fail");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "artifact_operation_failed",
message:
"Artifact contains an unsafe internal path. Inspect the reported path and correct the artifact before retrying.",
details: {
operation: "inventory_artifact",
reason: "path",
detail: 'Artifact path is absolute or unsafe: "a\\\\b.js"',
},
});
});
it("returns a destination case collision as the user-facing path message", () => {
const detail = destinationCaseCollisionMessage("res/2f.xml", ["2F.xml"]);
expect(detail).toEqual(expect.any(String));
if (detail === undefined) return;
expect(
projectAnalysisError(
new ArtifactOperationError("extract_artifact", "path", undefined, detail),
),
).toMatchObject({
message: detail,
details: { reason: "path", detail },
});
});

View File

@ -86,6 +86,12 @@ describe("Android application projection", () => {
expect(left.bridge_candidates).toEqual([
expect.objectContaining({ basis: "react-native-convention" }),
]);
expect(left.limitations).toEqual(
expect.arrayContaining([
"A bridge basis is inferred from the native path and is repeated for every managed component.",
"ZIP inventory cannot see the APK Signing Block, so an empty signing array does not mean the APK is unsigned.",
]),
);
expect(JSON.stringify(left)).not.toContain("opaque signing");
});
@ -146,6 +152,45 @@ describe("Android application projection", () => {
});
});
describe("case-distinct APK resource names", () => {
it("inventories and projects an APK whose resource names differ only in case", async () => {
const root = await createTestTempDirectory("rea-android-case-");
const path = join(root, "Shrunk.apk");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add(
"AndroidManifest.xml",
new Uint8ArrayReader(Uint8Array.from([3, 0, 8, 0])),
);
await writer.add(
"classes.dex",
new Uint8ArrayReader(
Uint8Array.from([0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0]),
),
);
await writer.add("res/2F.xml", new TextReader("upper"));
await writer.add("res/2f.xml", new TextReader("lower"));
await writeFile(path, await writer.close());
const inventory = parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {}),
);
const result = projectAndroidApplicationEvidence({
inventory_evidence: [inventory],
});
const projection = androidApplicationProjectionResultSchema.parse(
requireSuccessfulProjection(result).normalized_result,
);
expect(projection.root_format).toBe("apk");
expect(projection.components.manifests).toHaveLength(1);
expect(projection.components.dex).toHaveLength(1);
expect(JSON.stringify(inventory)).toContain("res/2F.xml");
expect(JSON.stringify(inventory)).toContain("res/2f.xml");
expect(JSON.stringify(inventory)).toContain(
"English (en-US) Unicode case folding",
);
});
});
describe("Android bridge candidate coverage", () => {
it("returns every component and bridge candidate from the inventory", async () => {
const root = await createTestTempDirectory("rea-android-complete-");