mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 06:46:10 +00:00
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:
1 parent
7296f2b5bf
commit
1f0775684b
11 files changed
+425
-95
No files matched your search
+157
-11
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user