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:
morlutoandCursor Agent authored and GitHub committed 2026-10-10 15:21:16 +08:00
1 parent 7296f2b5bf
commit 1f0775684b
11 files changed
+425 -95

No files matched your search

+157 -11
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;
};
+33 -2
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(
+16 -7
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
? []
: [
+14 -2
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 (