diff --git a/docs/android-analysis.md b/docs/android-analysis.md index efcb05c64..849aeb2b1 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -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":[]}' diff --git a/src/artifacts/ArtifactPaths.ts b/src/artifacts/ArtifactPaths.ts index d5ec35cb4..ce8d6c515 100644 --- a/src/artifacts/ArtifactPaths.ts +++ b/src/artifacts/ArtifactPaths.ts @@ -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; } + +interface CaseNode { + readonly bySpelling: Map; + readonly spellingsByFold: Map; + 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 => { + 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>(); + recordCaseCollisions(root, collisions); + return new Map( + [...collisions.entries()].map(([path, others]) => [ + path, + [...others].sort(compareUnicodeCodePoints), + ]), + ); +}; + +const recordCaseCollisions = ( + node: CaseNode, + collisions: Map>, +): 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>, +): 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>, +): void => { + for (const path of group) { + const set = collisions.get(path) ?? new Set(); + 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; +}; diff --git a/src/artifacts/SafeOutputTree.ts b/src/artifacts/SafeOutputTree.ts index fafb6fee4..05ce0ac68 100644 --- a/src/artifacts/SafeOutputTree.ts +++ b/src/artifacts/SafeOutputTree.ts @@ -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 => { + 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( diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index abd524693..0719e58f4 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -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; 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 ? [] : [ diff --git a/src/artifacts/inventory/scanReader.ts b/src/artifacts/inventory/scanReader.ts index 1ee691a74..89b1b628e 100644 --- a/src/artifacts/inventory/scanReader.ts +++ b/src/artifacts/inventory/scanReader.ts @@ -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; 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 ( diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 7c71a9b5d..b4fe4ad64 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -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 = 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, diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index 745b8e0c4..674425749 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -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")) diff --git a/src/domain/zipPackageFormat.ts b/src/domain/zipPackageFormat.ts index c45109135..39a8f86a5 100644 --- a/src/domain/zipPackageFormat.ts +++ b/src/domain/zipPackageFormat.ts @@ -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; diff --git a/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts b/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts index 368d029dc..92f88aa9d 100644 --- a/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts +++ b/tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts @@ -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) => { diff --git a/tests/boundary/providers/native/artifactProvider.failureDetail.test.ts b/tests/boundary/providers/native/artifactProvider.failureDetail.test.ts index dcec03c93..e2b2fd385 100644 --- a/tests/boundary/providers/native/artifactProvider.failureDetail.test.ts +++ b/tests/boundary/providers/native/artifactProvider.failureDetail.test.ts @@ -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 }, + }); +}); diff --git a/tests/composition/android/application.test.ts b/tests/composition/android/application.test.ts index 082a706b0..3529dc4e8 100644 --- a/tests/composition/android/application.test.ts +++ b/tests/composition/android/application.test.ts @@ -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-");