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:
parent
7296f2b5bf
commit
1f0775684b
|
|
@ -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>]}'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? []
|
||||
: [
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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-");
|
||||
|
|
|
|||
Loading…
Reference in New Issue