chore(hopper): sync current main for final publisher validation
Integrate current main 4692bb333 while preserving its unrelated source exactly.
The complete static, documentation and focused runtime gates run against this
merged source before the next push.
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
commit
0022c9af5b
25 files changed
+613
-94
No files matched your search
@@ -223,7 +223,10 @@ Selected bounded text is then parsed as inert data to recover:
|
||||
- Webpack and Rspack chunk registrations and module factories represented as
|
||||
AST literals;
|
||||
- static imports, dynamic imports, CommonJS `require` calls, workers, and
|
||||
service workers;
|
||||
service workers (only `new Worker(new URL(path, import.meta.url))` resolves
|
||||
relative to the calling module; string worker and service-worker paths use
|
||||
the document base URL or Node's working directory, which static analysis
|
||||
does not establish, so they keep their expression and stay unresolved);
|
||||
- route, network endpoint, storage, and vendor-marker observations;
|
||||
- local source-map declarations and original
|
||||
source names and content digests;
|
||||
@@ -629,6 +632,10 @@ href are read without surrounding whitespace or embedded tabs and newlines, and
|
||||
their percent-encoded path bytes are decoded; encoded dot and separator bytes
|
||||
are still rejected. CommonJS module lookups retain extension and directory
|
||||
resolution.
|
||||
ESM, HTML, and URL references apply URL dot segments to empty path segments:
|
||||
`./assets//../app.js` selects `assets/app.js`, while a remaining empty segment
|
||||
such as `./assets//app.js` stays unavailable. CommonJS keeps filesystem path
|
||||
semantics and selects `app.js` for the same text.
|
||||
Unresolved HTML references retain their declaration, source range, and resolution
|
||||
reason in the renderer observations.
|
||||
HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR
|
||||
|
||||
@@ -570,6 +570,12 @@ Exact and ambiguous value counts describe emitted root/object value instances;
|
||||
a root and an object may contain the same value. Archive digests identify the
|
||||
original bytes.
|
||||
|
||||
Dates are reported as `{ "$plist_type": "date", "iso8601": "<timestamp>" }`. A
|
||||
date the decoder cannot represent, such as an XML date with a year beyond 9999
|
||||
or a negative year, is reported as `{ "$plist_type": "date", "iso8601": null }`
|
||||
with a counted limitation; the rest of the plist or archive is still decoded.
|
||||
`inspect_plist` and Interface Builder decoding use the same projection.
|
||||
|
||||
## Locate a byte signature
|
||||
|
||||
Use `search_bytes` after opening a target with Ghidra to locate a known constant
|
||||
|
||||
@@ -110,9 +110,11 @@ const resolveInternalSpecifier = (
|
||||
const directoryReference =
|
||||
specifier.endsWith("/") || [".", ".."].includes(posix.basename(specifier));
|
||||
const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"];
|
||||
// A directory resolves only to an index with an extension; neither Node nor
|
||||
// TypeScript selects an extensionless `index` file.
|
||||
const candidates = suffixes.flatMap((suffix) => [
|
||||
...(directoryReference ? [] : [`${normalized}${suffix}`]),
|
||||
posix.join(normalized, `index${suffix}`),
|
||||
...(suffix === "" ? [] : [posix.join(normalized, `index${suffix}`)]),
|
||||
]);
|
||||
// Retain present runtime files first; substitute only a missing counterpart.
|
||||
// Counterparts cover explicit filenames, not directory/index resolution.
|
||||
|
||||
@@ -2495,20 +2495,61 @@ describe("url-reference directory and empty-segment semantics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["./nested//worker.js", "./nested//../worker.js"])(
|
||||
"does not collapse empty URL path segments: %s",
|
||||
(declaredPath) => {
|
||||
it.each([
|
||||
["./nested//worker.js", null],
|
||||
["./nested//../worker.js", "dist/nested/worker.js"],
|
||||
])(
|
||||
"keeps empty URL path segments significant: %s",
|
||||
(declaredPath, resolvedPath) => {
|
||||
// A dot segment removes the preceding empty segment, never `nested`.
|
||||
expect(new URL(declaredPath, "file:///root/dist/app.mjs").pathname).toBe(
|
||||
resolvedPath === null
|
||||
? "/root/dist/nested//worker.js"
|
||||
: `/root/${resolvedPath}`,
|
||||
);
|
||||
for (const context of ["url-reference", "html-reference"] as const)
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath,
|
||||
sourcePath: "dist/app.mjs",
|
||||
context,
|
||||
files: fileMap([
|
||||
file("dist/worker.js", "root"),
|
||||
file("dist/nested/worker.js", "root"),
|
||||
]),
|
||||
}),
|
||||
context,
|
||||
).toMatchObject({ resolved_path: resolvedPath });
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath,
|
||||
sourcePath: "dist/app.mjs",
|
||||
context: "url-reference",
|
||||
context: "module-specifier",
|
||||
moduleKind: "import",
|
||||
files: fileMap([
|
||||
file("dist/worker.js", "root"),
|
||||
file("dist/nested/worker.js", "root"),
|
||||
]),
|
||||
}),
|
||||
).toMatchObject({ resolved_path: null });
|
||||
).toMatchObject({ resolved_path: resolvedPath });
|
||||
// CommonJS keeps filesystem path semantics.
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath,
|
||||
sourcePath: "dist/app.mjs",
|
||||
context: "module-specifier",
|
||||
moduleKind: "require",
|
||||
files: fileMap([
|
||||
file("dist/worker.js", "root"),
|
||||
file("dist/nested/worker.js", "root"),
|
||||
]),
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolved_path:
|
||||
declaredPath === "./nested//worker.js"
|
||||
? "dist/nested/worker.js"
|
||||
: "dist/worker.js",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -551,22 +551,24 @@ const contextualCandidate = (
|
||||
return unresolvedCandidate("rejected", [
|
||||
"The URL reference path percent-decodes to NUL or to bytes that are not UTF-8.",
|
||||
]);
|
||||
// Empty URL segments are significant; canonical filesystem paths cannot
|
||||
// preserve them, including their interaction with following dot segments.
|
||||
if (decoded.includes("//"))
|
||||
return unresolvedCandidate("unavailable", [
|
||||
"Repeated URL path separators cannot be mapped to an exact canonical artifact file.",
|
||||
]);
|
||||
return exactUrlPathCandidate(
|
||||
const candidate = urlRelativeCandidate(
|
||||
posix.dirname(input.sourcePath),
|
||||
decoded,
|
||||
relativeCandidate(input.sourcePath, decoded),
|
||||
"URL",
|
||||
);
|
||||
return typeof candidate === "string"
|
||||
? exactUrlPathCandidate(decoded, candidate, "URL")
|
||||
: candidate;
|
||||
}
|
||||
const candidate = relativeCandidate(input.sourcePath, declared);
|
||||
return context === "module-specifier" && input.moduleKind === "import"
|
||||
? exactUrlPathCandidate(declared, candidate, "module URL")
|
||||
: candidate;
|
||||
if (context === "module-specifier" && input.moduleKind === "import") {
|
||||
const candidate = urlRelativeCandidate(
|
||||
posix.dirname(input.sourcePath),
|
||||
declared,
|
||||
);
|
||||
return typeof candidate === "string"
|
||||
? exactUrlPathCandidate(declared, candidate, "module URL")
|
||||
: candidate;
|
||||
}
|
||||
return relativeCandidate(input.sourcePath, declared);
|
||||
};
|
||||
|
||||
const decodeModuleUrlPath = (
|
||||
@@ -593,6 +595,78 @@ const relativeCandidate = (sourcePath: string, declared: string): string =>
|
||||
? declared.slice(1)
|
||||
: posix.join(posix.dirname(sourcePath), declared);
|
||||
|
||||
/**
|
||||
* A URL path during reference resolution. Above-root `..` markers are kept
|
||||
* apart from segments, so a later `..` cannot cancel an escape, while empty
|
||||
* segments stay removable until the final path is formed.
|
||||
*/
|
||||
interface UrlPathState {
|
||||
readonly escapes: number;
|
||||
readonly segments: readonly string[];
|
||||
}
|
||||
|
||||
const urlDirectoryState = (directory: string): UrlPathState => ({
|
||||
escapes: 0,
|
||||
segments: directory === "" || directory === "." ? [] : directory.split("/"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply URL dot segments: `..` removes the previous segment even when it is
|
||||
* empty, so `assets//../app.js` names `assets/app.js`. A final `.` or `..`
|
||||
* leaves directory form, as the URL parser does.
|
||||
*/
|
||||
const applyUrlReference = (
|
||||
base: UrlPathState,
|
||||
reference: string,
|
||||
): UrlPathState => {
|
||||
const absolute = reference.startsWith("/");
|
||||
const segments = absolute ? [] : [...base.segments];
|
||||
let escapes = absolute ? 0 : base.escapes;
|
||||
const parts = (absolute ? reference.slice(1) : reference).split("/");
|
||||
for (const [index, part] of parts.entries()) {
|
||||
if (part !== "." && part !== "..") {
|
||||
segments.push(part);
|
||||
continue;
|
||||
}
|
||||
if (part === "..")
|
||||
if (segments.length > 0) segments.pop();
|
||||
else escapes += 1;
|
||||
if (index === parts.length - 1) segments.push("");
|
||||
}
|
||||
return { escapes, segments };
|
||||
};
|
||||
|
||||
/** A relative reference replaces the base URL's final path segment. */
|
||||
const urlBaseDirectory = (base: UrlPathState): UrlPathState => ({
|
||||
escapes: base.escapes,
|
||||
segments: base.segments.slice(0, -1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Form the artifact candidate. A remaining empty segment cannot name a
|
||||
* canonical artifact file; a trailing one is directory form. Confinement
|
||||
* rejects a candidate that keeps an above-root marker.
|
||||
*/
|
||||
const urlPathCandidate = (path: UrlPathState): string | CandidateResolution => {
|
||||
const segments =
|
||||
path.segments.at(-1) === "" ? path.segments.slice(0, -1) : path.segments;
|
||||
if (segments.includes(""))
|
||||
return unresolvedCandidate("unavailable", [
|
||||
"Repeated URL path separators cannot be mapped to an exact canonical artifact file.",
|
||||
]);
|
||||
return [
|
||||
...Array.from({ length: path.escapes }, () => ".."),
|
||||
...segments,
|
||||
].join("/");
|
||||
};
|
||||
|
||||
/** Resolve a URL path reference against a canonical directory. */
|
||||
const urlRelativeCandidate = (
|
||||
directory: string,
|
||||
reference: string,
|
||||
): string | CandidateResolution =>
|
||||
urlPathCandidate(applyUrlReference(urlDirectoryState(directory), reference));
|
||||
|
||||
const bareModuleCandidate = (
|
||||
input: ResolveArtifactPathInput,
|
||||
): CandidateResolution => {
|
||||
@@ -1066,11 +1140,12 @@ const htmlCandidate = (
|
||||
return unresolvedCandidate("rejected", [
|
||||
"The HTML reference path percent-decodes to NUL or to bytes that are not UTF-8.",
|
||||
]);
|
||||
if (declaredPath.startsWith("/")) return declaredPath.slice(1);
|
||||
if (declaredPath.startsWith("/"))
|
||||
return urlRelativeCandidate("", declaredPath);
|
||||
if (base === undefined || base === null || base === "")
|
||||
return declaredPath === ""
|
||||
? input.sourcePath
|
||||
: posix.join(posix.dirname(input.sourcePath), declaredPath);
|
||||
: urlRelativeCandidate(posix.dirname(input.sourcePath), declaredPath);
|
||||
// A local base href is a second untrusted path input; apply the same
|
||||
// admission rules a declared path gets so it cannot smuggle traversal or
|
||||
// separator syntax past canonicalization.
|
||||
@@ -1083,12 +1158,21 @@ const htmlCandidate = (
|
||||
return unresolvedCandidate("rejected", [
|
||||
"The document base href path percent-decodes to NUL or to bytes that are not UTF-8.",
|
||||
]);
|
||||
const basePath = decodedBase.startsWith("/")
|
||||
? decodedBase.slice(1)
|
||||
: posix.join(posix.dirname(input.sourcePath), decodedBase);
|
||||
if (declaredPath === "")
|
||||
return exactUrlPathCandidate(decodedBase, basePath, "HTML");
|
||||
return posix.join(htmlBaseDirectory(decodedBase, basePath), declaredPath);
|
||||
// Keep the base's empty segments and escapes until the reference is
|
||||
// applied; only the final path must name a canonical artifact file.
|
||||
const baseState = applyUrlReference(
|
||||
urlDirectoryState(posix.dirname(input.sourcePath)),
|
||||
decodedBase,
|
||||
);
|
||||
if (declaredPath === "") {
|
||||
const basePath = urlPathCandidate(baseState);
|
||||
return typeof basePath === "string"
|
||||
? exactUrlPathCandidate(decodedBase, basePath, "HTML")
|
||||
: basePath;
|
||||
}
|
||||
return urlPathCandidate(
|
||||
applyUrlReference(urlBaseDirectory(baseState), declaredPath),
|
||||
);
|
||||
};
|
||||
|
||||
const exactUrlPathCandidate = (
|
||||
@@ -1112,9 +1196,6 @@ const urlPathIsDirectory = (path: string): boolean =>
|
||||
path === "." ||
|
||||
path === "..";
|
||||
|
||||
const htmlBaseDirectory = (base: string, basePath: string): string =>
|
||||
urlPathIsDirectory(base) ? basePath : posix.dirname(basePath);
|
||||
|
||||
const confineCandidate = (candidate: string): string | CandidateResolution => {
|
||||
const normalized = normalizeJoinedLogicalPath(candidate);
|
||||
if (logicalPathEscapesRoot(normalized))
|
||||
|
||||
@@ -6,6 +6,7 @@ const paths = [
|
||||
"assets/app.js",
|
||||
"renderer/app.js",
|
||||
"renderer/a/app.js",
|
||||
"renderer/assets/app.js",
|
||||
"app.js",
|
||||
];
|
||||
const files = new Map<string, JavaScriptArtifactFile>(
|
||||
@@ -105,6 +106,26 @@ describe("HTML base href dot segments", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
describe("HTML base href empty segments", () => {
|
||||
it("lets the script reference remove an empty segment from the base", () => {
|
||||
// URL resolution keeps the base's empty segment until `..` removes it.
|
||||
expect(
|
||||
new URL("../app.js", new URL("./assets//", documentUrl)).pathname,
|
||||
).toBe("/renderer/assets/app.js");
|
||||
expect(resolve("../app.js", "./assets//")).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "renderer/assets/app.js",
|
||||
});
|
||||
});
|
||||
it("keeps an above-root base escape when the reference also climbs", () => {
|
||||
expect(resolve("../app.js", "../../")).toMatchObject({
|
||||
resolution_status: "rejected",
|
||||
limitations: [
|
||||
"The resolved candidate escapes the canonical artifact root.",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("HTML base href path syntax", () => {
|
||||
it.each(["%2e%2e/secret/", "/a/%2e%2e/", "/a\\b/", "a\0b"])(
|
||||
"rejects base href %j with the same admission rules a declared path gets",
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
isUIKitRuntimeConnectionClass,
|
||||
} from "../../domain/apple/interfaceBuilderKeyedArchive.js";
|
||||
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { projectPlistValue } from "../../domain/apple/plistValue.js";
|
||||
import {
|
||||
projectPlistValue,
|
||||
unknownDateLimitation,
|
||||
} from "../../domain/apple/plistValue.js";
|
||||
import {
|
||||
omittedPrototypeKeysLimitation,
|
||||
parseXmlPropertyList,
|
||||
@@ -58,6 +61,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
const incompleteHierarchies = new Map<string, number>();
|
||||
const unresolvedConnections = new Map<string, number>();
|
||||
const prototypeKeyOmissions = new Map<string, number | null>();
|
||||
const unknownDates = new Map<string, number>();
|
||||
let omitted = 0;
|
||||
let attempted = 0;
|
||||
let aggregateInputBytes = 0;
|
||||
@@ -131,16 +135,21 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
binaryPrototypeKeyObserved =
|
||||
decodedReservation.binaryPrototypeKeyObserved;
|
||||
}
|
||||
const { value: raw, omittedPrototypeKeys } =
|
||||
nib === null
|
||||
? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved)
|
||||
: { value: nib.raw, omittedPrototypeKeys: 0 };
|
||||
const {
|
||||
value: raw,
|
||||
omittedPrototypeKeys,
|
||||
unknownDateCount,
|
||||
} = nib === null
|
||||
? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved)
|
||||
: { value: nib.raw, omittedPrototypeKeys: 0, unknownDateCount: 0 };
|
||||
if (nib !== null && nib.omitted > 0)
|
||||
incompleteHierarchies.set(entry.path, nib.omitted);
|
||||
if (nib !== null && nib.unresolvedConnections > 0)
|
||||
unresolvedConnections.set(entry.path, nib.unresolvedConnections);
|
||||
if (omittedPrototypeKeys !== 0)
|
||||
prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys);
|
||||
if (unknownDateCount !== 0)
|
||||
unknownDates.set(entry.path, unknownDateCount);
|
||||
const documentHash = createHash("sha256").update(bytes).digest("hex");
|
||||
documents.push({
|
||||
relativePath: entry.path,
|
||||
@@ -178,6 +187,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
incompleteHierarchies,
|
||||
unresolvedConnections,
|
||||
prototypeKeyOmissions,
|
||||
unknownDates,
|
||||
invalid,
|
||||
omittedArchives,
|
||||
unreportedOmittedArchives,
|
||||
@@ -246,6 +256,7 @@ const finalizeHierarchyCoverage = (
|
||||
incompleteHierarchies: ReadonlyMap<string, number>;
|
||||
unresolvedConnections: ReadonlyMap<string, number>;
|
||||
prototypeKeyOmissions: ReadonlyMap<string, number | null>;
|
||||
unknownDates: ReadonlyMap<string, number>;
|
||||
invalid: readonly string[];
|
||||
omittedArchives: readonly { path: string; reason: string }[];
|
||||
unreportedOmittedArchives: number;
|
||||
@@ -257,6 +268,7 @@ const finalizeHierarchyCoverage = (
|
||||
incompleteHierarchies,
|
||||
unresolvedConnections,
|
||||
prototypeKeyOmissions,
|
||||
unknownDates,
|
||||
invalid,
|
||||
omittedArchives,
|
||||
unreportedOmittedArchives,
|
||||
@@ -342,6 +354,9 @@ const finalizeHierarchyCoverage = (
|
||||
...[...prototypeKeyOmissions].map(
|
||||
([path, count]) => `${path}: ${omittedPrototypeKeysLimitation(count)}`,
|
||||
),
|
||||
...[...unknownDates].map(
|
||||
([path, count]) => `${path}: ${unknownDateLimitation(count)}`,
|
||||
),
|
||||
...omittedArchives.map(
|
||||
({ path, reason }) =>
|
||||
`${path}: omitted because ${reason.replaceAll("_", " ")}.`,
|
||||
@@ -693,6 +708,7 @@ const decodePlist = (
|
||||
): {
|
||||
readonly value: JsonValue;
|
||||
readonly omittedPrototypeKeys: number | null;
|
||||
readonly unknownDateCount: number;
|
||||
} => {
|
||||
const binary = hasBinaryPlistSignature(bytes);
|
||||
const { value, omittedPrototypeKeys } = binary
|
||||
@@ -701,5 +717,10 @@ const decodePlist = (
|
||||
omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0,
|
||||
}
|
||||
: parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes));
|
||||
return { value: projectPlistValue(value).value, omittedPrototypeKeys };
|
||||
const projected = projectPlistValue(value);
|
||||
return {
|
||||
value: projected.value,
|
||||
omittedPrototypeKeys,
|
||||
unknownDateCount: projected.unknownDateCount,
|
||||
};
|
||||
};
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
AnalysisInputError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { projectPlistValue } from "../../domain/apple/plistValue.js";
|
||||
import {
|
||||
projectPlistValue,
|
||||
unknownDateLimitation,
|
||||
} from "../../domain/apple/plistValue.js";
|
||||
import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js";
|
||||
import {
|
||||
binaryPlistNumberLiterals,
|
||||
@@ -75,9 +78,11 @@ export const decodeKeyedArchiveBytes = (
|
||||
: 0,
|
||||
}
|
||||
: parseXmlPropertyList(xmlText ?? "");
|
||||
const { value: plistValue, unknownRealCount } = projectPlistValue(
|
||||
parsed.value,
|
||||
);
|
||||
const {
|
||||
value: plistValue,
|
||||
unknownRealCount,
|
||||
unknownDateCount,
|
||||
} = projectPlistValue(parsed.value);
|
||||
const graph = projectKeyedArchive(plistValue, selection);
|
||||
const numbers = createPlistNumberProjection(
|
||||
binary
|
||||
@@ -112,6 +117,9 @@ export const decodeKeyedArchiveBytes = (
|
||||
: [
|
||||
`${String(unknownRealCount)} non-finite real value(s) are reported as { "$plist_type": "real", "value": null } because JSON evidence cannot preserve NaN vs infinity.`,
|
||||
]),
|
||||
...(unknownDateCount === 0
|
||||
? []
|
||||
: [unknownDateLimitation(unknownDateCount)]),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,20 @@ import { expect, it } from "vitest";
|
||||
|
||||
import { projectPlistValue } from "./plistValue.js";
|
||||
|
||||
// Binary decoders build dates from seconds; beyond the Date range they are invalid.
|
||||
it("projects an unrepresentable date as unknown and counts it", () => {
|
||||
expect(
|
||||
projectPlistValue({ far: new Date(Number.NaN), near: new Date(0) }),
|
||||
).toEqual({
|
||||
value: {
|
||||
far: { $plist_type: "date", iso8601: null },
|
||||
near: { $plist_type: "date", iso8601: "1970-01-01T00:00:00.000Z" },
|
||||
},
|
||||
unknownRealCount: 0,
|
||||
unknownDateCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects excessive plist nesting without exhausting the stack", () => {
|
||||
const deep: unknown[] = [];
|
||||
let cursor = deep;
|
||||
|
||||
@@ -14,8 +14,14 @@ export interface ProjectedPlistValue {
|
||||
readonly value: JsonValue;
|
||||
/** Reals decoded as NaN, which the decoder also uses for ±infinity. */
|
||||
readonly unknownRealCount: number;
|
||||
/** Dates the decoder could not represent, reported without a timestamp. */
|
||||
readonly unknownDateCount: number;
|
||||
}
|
||||
|
||||
/** Limitation for results whose plist held dates the decoder could not represent. */
|
||||
export const unknownDateLimitation = (count: number): string =>
|
||||
`${String(count)} date value(s) are reported as { "$plist_type": "date", "iso8601": null } because the decoder could not represent them, such as XML dates with years beyond 9999 or negative years, or dates outside the JavaScript date range.`;
|
||||
|
||||
/**
|
||||
* Project a decoded plist into JSON. Data and dates become explicit
|
||||
* `$plist_type` objects; a non-finite real becomes an explicit unknown
|
||||
@@ -23,6 +29,7 @@ export interface ProjectedPlistValue {
|
||||
*/
|
||||
export const projectPlistValue = (value: unknown): ProjectedPlistValue => {
|
||||
let unknownRealCount = 0;
|
||||
let unknownDateCount = 0;
|
||||
const project = (item: unknown, depth: number): JsonValue => {
|
||||
if (depth > MAX_PLIST_DEPTH)
|
||||
throw new RangeError(
|
||||
@@ -33,8 +40,12 @@ export const projectPlistValue = (value: unknown): ProjectedPlistValue => {
|
||||
$plist_type: "data",
|
||||
base64: Buffer.from(item).toString("base64"),
|
||||
};
|
||||
if (item instanceof Date)
|
||||
return { $plist_type: "date", iso8601: item.toISOString() };
|
||||
if (item instanceof Date) {
|
||||
if (!Number.isNaN(item.getTime()))
|
||||
return { $plist_type: "date", iso8601: item.toISOString() };
|
||||
unknownDateCount += 1;
|
||||
return { $plist_type: "date", iso8601: null };
|
||||
}
|
||||
if (typeof item === "number" && !Number.isFinite(item)) {
|
||||
unknownRealCount += 1;
|
||||
return { $plist_type: "real", value: null };
|
||||
@@ -51,5 +62,5 @@ export const projectPlistValue = (value: unknown): ProjectedPlistValue => {
|
||||
return z.union([z.string(), z.number(), z.boolean(), z.null()]).parse(item);
|
||||
};
|
||||
const projected = project(value, 0);
|
||||
return { value: projected, unknownRealCount };
|
||||
return { value: projected, unknownRealCount, unknownDateCount };
|
||||
};
|
||||
@@ -27,11 +27,12 @@ describe("static callee boundary matching", () => {
|
||||
["./bare.html", "call:loadFile"],
|
||||
["https://optional.example.test", "call:win.loadURL"],
|
||||
]);
|
||||
// The document base URL is unknown, so the script path stays unresolved.
|
||||
expect(
|
||||
analysis.references
|
||||
.filter(({ kind }) => kind === "service-worker")
|
||||
.map(({ specifier }) => specifier),
|
||||
).toEqual(["./worker.js"]);
|
||||
.map(({ specifier, expression }) => [specifier, expression]),
|
||||
).toEqual([[null, 'navigator.serviceWorker.register("./worker.js")']]);
|
||||
expect(
|
||||
analysis.endpoints
|
||||
.filter(({ mechanism }) => mechanism.endsWith("loadURL"))
|
||||
|
||||
@@ -95,19 +95,25 @@ export const inspectCall = (
|
||||
addReference(context, {
|
||||
node,
|
||||
kind: "worker",
|
||||
// Node string paths use the process cwd or filesystem root, neither of
|
||||
// which is established by the analyzed source's artifact location.
|
||||
// A string path is relative to the document base URL in a browser and
|
||||
// to the process cwd in Node; the analyzed source's artifact location
|
||||
// establishes neither. Only `new URL(path, import.meta.url)` names a
|
||||
// location relative to this module.
|
||||
specifier:
|
||||
(workerConstructor === "node" ? undefined : first) ??
|
||||
(workerConstructor === undefined || worker === undefined
|
||||
workerConstructor === undefined || worker === undefined
|
||||
? undefined
|
||||
: workerUrlSpecifier(worker, bindings)),
|
||||
: workerUrlSpecifier(worker, bindings),
|
||||
});
|
||||
// Service worker script URLs are also relative to the document base URL.
|
||||
if (
|
||||
name === "serviceWorker.register" ||
|
||||
name.endsWith(".serviceWorker.register")
|
||||
)
|
||||
addReference(context, { node, kind: "service-worker", specifier: first });
|
||||
addReference(context, {
|
||||
node,
|
||||
kind: "service-worker",
|
||||
specifier: undefined,
|
||||
});
|
||||
inspectEndpointCall(node, name, first, context);
|
||||
inspectStorageCall(node, name, first, context);
|
||||
if (first === "audio" || first === "video")
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
semanticWriteSiteTarget,
|
||||
semanticWriteTargets,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascript/javascriptAstValues.js";
|
||||
import { analyzeParsedJavaScriptReferences } from "./javascript/javascriptSemanticAnalysis.js";
|
||||
import {
|
||||
semanticBindingId,
|
||||
@@ -49,6 +55,22 @@ export const unshadowedReferenceSourceRequireCalls = (
|
||||
(reference.resolution === "unbound" ||
|
||||
reference.bindingId === programRequireBinding),
|
||||
);
|
||||
const isLoaderReference = (identifier: t.Identifier): boolean => {
|
||||
const start = identifier.loc?.start;
|
||||
const reference =
|
||||
start === undefined
|
||||
? undefined
|
||||
: references.get(`${start.line}:${start.column}`);
|
||||
return (
|
||||
(allowsUnboundRequire && reference?.resolution === "unbound") ||
|
||||
(reference?.resolution === "resolved" &&
|
||||
reference.bindingId === programRequireBinding)
|
||||
);
|
||||
};
|
||||
const loaderMembersIntact = requireLoaderMembersIntact(
|
||||
file.program,
|
||||
isLoaderReference,
|
||||
);
|
||||
const calls = new Set<t.CallExpression>();
|
||||
|
||||
traverseJavaScriptAst(file.program, {
|
||||
@@ -64,6 +86,9 @@ export const unshadowedReferenceSourceRequireCalls = (
|
||||
)
|
||||
)
|
||||
return;
|
||||
// `require.resolve` and `require.main` denote Node's members only
|
||||
// while no visible write or escape can have replaced them.
|
||||
if (!t.isIdentifier(node.callee) && !loaderMembersIntact) return;
|
||||
const reference = references.get(`${start.line}:${start.column}`);
|
||||
if (
|
||||
(allowsUnboundRequire &&
|
||||
@@ -80,6 +105,68 @@ export const unshadowedReferenceSourceRequireCalls = (
|
||||
return calls;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the loader's `resolve` and `main` members keep Node's identity. A
|
||||
* write to either member or to a computed member, or `require` used as a
|
||||
* value other than a call or member read (where a mutator or alias can
|
||||
* replace members), leaves that identity unknown.
|
||||
*/
|
||||
const requireLoaderMembersIntact = (
|
||||
program: t.Program,
|
||||
isLoaderReference: (identifier: t.Identifier) => boolean,
|
||||
): boolean => {
|
||||
let intact = true;
|
||||
const isLoaderMemberTarget = (target: t.Node): boolean => {
|
||||
if (!t.isMemberExpression(target) && !t.isOptionalMemberExpression(target))
|
||||
return false;
|
||||
const object = unwrapJavaScriptExpression(target.object).node;
|
||||
if (!t.isIdentifier(object, { name: "require" })) return false;
|
||||
if (!isLoaderReference(object)) return false;
|
||||
const key = semanticStaticPropertyKey(target.property, target.computed);
|
||||
return key === null || key === "resolve" || key === "main";
|
||||
};
|
||||
traverseJavaScriptAst(program, {
|
||||
enter: (node, parent, readAncestors) => {
|
||||
if (!intact) return;
|
||||
const site = semanticWriteSiteTarget(node);
|
||||
if (site !== null) {
|
||||
for (const target of semanticWriteTargets(site))
|
||||
if (isLoaderMemberTarget(target)) intact = false;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!t.isIdentifier(node, { name: "require" }) ||
|
||||
parent === null ||
|
||||
!isLoaderReference(node)
|
||||
)
|
||||
return;
|
||||
// Inert parentheses and TypeScript wrappers do not change how the
|
||||
// loader value is used.
|
||||
let used: t.Node = node;
|
||||
let user: t.Node | undefined = parent;
|
||||
const read = readAncestors();
|
||||
const ancestors = read.at(-1) === parent ? read : [...read, parent];
|
||||
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
||||
user = ancestors[index];
|
||||
if (
|
||||
user === undefined ||
|
||||
unwrapJavaScriptExpression(user).node !== node
|
||||
)
|
||||
break;
|
||||
used = user;
|
||||
}
|
||||
const usedAsLoader =
|
||||
((t.isCallExpression(user) || t.isOptionalCallExpression(user)) &&
|
||||
user.callee === used) ||
|
||||
((t.isMemberExpression(user) || t.isOptionalMemberExpression(user)) &&
|
||||
user.object === used) ||
|
||||
t.isUnaryExpression(user, { operator: "typeof" });
|
||||
if (!usedAsLoader) intact = false;
|
||||
},
|
||||
});
|
||||
return intact;
|
||||
};
|
||||
|
||||
const contains = (container: t.Node, node: t.Node): boolean =>
|
||||
typeof container.start === "number" &&
|
||||
typeof container.end === "number" &&
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
projectPlistValue,
|
||||
unknownDateLimitation,
|
||||
type ProjectedPlistValue,
|
||||
} from "../../domain/apple/plistValue.js";
|
||||
import {
|
||||
@@ -178,6 +179,9 @@ export const parsePlistXml = (
|
||||
: [
|
||||
`${String(projected.unknownRealCount)} non-finite real value(s) are reported as { "$plist_type": "real", "value": null } because the XML decoder does not distinguish NaN from infinity.`,
|
||||
]),
|
||||
...(projected.unknownDateCount === 0
|
||||
? []
|
||||
: [unknownDateLimitation(projected.unknownDateCount)]),
|
||||
...numberLimitations(exactIntegerCount, ambiguousNumberCount),
|
||||
...prototypeKeyLimitations(omittedPrototypeKeys),
|
||||
],
|
||||
|
||||
@@ -541,7 +541,7 @@ it("resolves source-map URL paths once without changing their declarations", asy
|
||||
await writeFile(
|
||||
join(root, "app.js"),
|
||||
cases.map(([url]) => `//# sourceMappingURL=${url}\n`).join("") +
|
||||
'new Worker("./worker%20file.js");\n' +
|
||||
'new Worker(new URL("./worker%20file.js", import.meta.url));\n' +
|
||||
'navigator.serviceWorker.register("./service%20worker.js");\n',
|
||||
);
|
||||
await writeFile(join(root, "worker file.js"), "export const worker = 1;\n");
|
||||
@@ -598,27 +598,41 @@ it("resolves source-map URL paths once without changing their declarations", asy
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const [kind, specifier, path] of [
|
||||
["worker", "./worker%20file.js", "worker file.js"],
|
||||
["service-worker", "./service%20worker.js", "service worker.js"],
|
||||
]) {
|
||||
const worker = graph.nodes.find((node) => node.kind === kind);
|
||||
expect(worker?.observations).toContainEqual(
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
declared_specifier: specifier,
|
||||
resolved_path: path,
|
||||
}),
|
||||
const worker = graph.nodes.find((node) => node.kind === "worker");
|
||||
expect(worker?.observations).toContainEqual(
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
declared_specifier: "./worker%20file.js",
|
||||
resolved_path: "worker file.js",
|
||||
}),
|
||||
);
|
||||
expect(graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source_node_id: worker?.node_id,
|
||||
relation: "maps_to",
|
||||
properties: { resolved_path: path },
|
||||
}),
|
||||
);
|
||||
expect(graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source_node_id: worker?.node_id,
|
||||
relation: "maps_to",
|
||||
properties: { resolved_path: "worker file.js" },
|
||||
}),
|
||||
);
|
||||
// A service worker script URL is relative to the unestablished document base.
|
||||
const serviceWorker = graph.nodes.find(
|
||||
(node) => node.kind === "service-worker",
|
||||
);
|
||||
expect(serviceWorker?.observations).toContainEqual(
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
declared_specifier: null,
|
||||
expression: 'navigator.serviceWorker.register("./service%20worker.js")',
|
||||
resolved_path: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
graph.edges.some(
|
||||
({ source_node_id, relation }) =>
|
||||
source_node_id === serviceWorker?.node_id && relation === "maps_to",
|
||||
),
|
||||
).toBe(false);
|
||||
expect((await analyzeThroughStdioMcp(root)).graph).toEqual(graph);
|
||||
}, 20_000);
|
||||
|
||||
|
||||
@@ -279,12 +279,6 @@ cliTest(
|
||||
specifier: "./worker.js",
|
||||
resolved: "bundled/worker.js",
|
||||
},
|
||||
{
|
||||
source: "nested/literal.mjs",
|
||||
expression: 'new Worker("./literal-worker.js");',
|
||||
specifier: "./literal-worker.js",
|
||||
resolved: "nested/literal-worker.js",
|
||||
},
|
||||
];
|
||||
await writeFixtureFiles(root, {
|
||||
...Object.fromEntries(
|
||||
@@ -431,9 +425,6 @@ cliTest(
|
||||
"directory-dot.mjs": "./worker.js/.",
|
||||
"directory-parent.mjs": "./worker.js/child/..",
|
||||
"empty-segment.mjs": "./child//../worker.js",
|
||||
"empty-worker-url.mjs": "",
|
||||
"query-only-worker-url.mjs": "?cache=1",
|
||||
"fragment-only-worker-url.mjs": "#worker",
|
||||
"package.mjs": "./package-worker",
|
||||
"external.mjs": "https://example.test/worker.js",
|
||||
};
|
||||
@@ -478,6 +469,57 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"keeps string Worker paths unresolved because the document base is unknown",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-worker-document-base-");
|
||||
// A browser resolves these strings against the document base URL, not the
|
||||
// calling script; Node resolves them against the working directory.
|
||||
const expressions = {
|
||||
"scripts/main.js": 'new Worker("./worker.js")',
|
||||
"scripts/service.js": 'navigator.serviceWorker.register("./sw.js")',
|
||||
};
|
||||
await writeFixtureFiles(root, {
|
||||
"index.html": '<script src="./scripts/main.js"></script>',
|
||||
...Object.fromEntries(
|
||||
Object.entries(expressions).map(([path, code]) => [path, `${code};`]),
|
||||
),
|
||||
"worker.js": "self.postMessage('document-relative worker');",
|
||||
"scripts/worker.js": "self.postMessage('script-relative decoy');",
|
||||
"scripts/sw.js": "self.postMessage('script-relative decoy');",
|
||||
});
|
||||
|
||||
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
|
||||
for (const [source, expression] of Object.entries(expressions)) {
|
||||
const node = result.graph.nodes.find(
|
||||
({ kind, observations }) =>
|
||||
(kind === "worker" || kind === "service-worker") &&
|
||||
observations.some(
|
||||
({ evidence }) =>
|
||||
evidence.location.available &&
|
||||
evidence.location.value.kind === "source-range" &&
|
||||
evidence.location.value.source === source,
|
||||
),
|
||||
);
|
||||
expect(node?.observations).toContainEqual(
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
declared_specifier: null,
|
||||
expression,
|
||||
resolved_path: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
result.graph.edges.some(
|
||||
({ relation, source_node_id }) =>
|
||||
relation === "maps_to" && source_node_id === node?.node_id,
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"bounds repeated ambient alias branches through the compiled CLI",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -96,6 +96,8 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
"<key>CFBundleIdentifier</key><string>com.example.types</string>",
|
||||
"<key>Blob</key><data>AAEC</data>",
|
||||
"<key>Built</key><date>2020-01-01T00:00:00Z</date>",
|
||||
// plutil writes years past 9999 unsigned, which the decoder rejects.
|
||||
"<key>Far</key><date>11507-08-17T05:20:00Z</date>",
|
||||
"<key>Ratio</key><real>nan</real>",
|
||||
"<key>Nested</key><array><dict>",
|
||||
"<key>Ratio</key><real>0.5</real>",
|
||||
@@ -130,6 +132,7 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
$plist_type: "date",
|
||||
iso8601: "2020-01-01T00:00:00.000Z",
|
||||
},
|
||||
Far: { $plist_type: "date", iso8601: null },
|
||||
Ratio: { $plist_type: "real", value: null },
|
||||
Nested: [
|
||||
{
|
||||
@@ -148,6 +151,7 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
limitations: [
|
||||
expect.stringContaining("XML conversion"),
|
||||
expect.stringContaining("2 non-finite real"),
|
||||
expect.stringContaining("1 date value(s)"),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,11 +29,13 @@ describe.skipIf(process.platform === "win32")(
|
||||
["direct.cjs", "./lib.js", "lib.js"],
|
||||
["fallback.cjs", "./fallback", "fallback/index.js"],
|
||||
["legacy-order.cjs", "./dep", "dep/index.js"],
|
||||
["bare-index.cjs", "./bare", "bare.js"],
|
||||
] as const;
|
||||
const unresolved = [
|
||||
["missing.cjs", "./missing/", "missing/"],
|
||||
["escape.cjs", "../outside/", "../outside/"],
|
||||
["main.ts", "./folder.js/.", "folder.js"],
|
||||
["only-bare.cjs", "./only/", "only/"],
|
||||
] as const;
|
||||
const files = new Map<string, string>([
|
||||
["index.js", 'module.exports = "root index";\n'],
|
||||
@@ -48,6 +50,9 @@ describe.skipIf(process.platform === "win32")(
|
||||
'export const value = "emitted filename counterpart";\n',
|
||||
],
|
||||
["missing.js", 'module.exports = "not a directory";\n'],
|
||||
["bare.js", 'module.exports = "file before directory";\n'],
|
||||
["bare/index", 'module.exports = "extensionless index";\n'],
|
||||
["only/index", 'module.exports = "extensionless index";\n'],
|
||||
...[...imports, ...unresolved].map(
|
||||
([path, specifier]) =>
|
||||
[path, `require(${JSON.stringify(specifier)});\n`] as const,
|
||||
@@ -75,6 +80,9 @@ describe.skipIf(process.platform === "win32")(
|
||||
expect(() =>
|
||||
createRequire(join(root, "main.ts")).resolve("./folder.js/."),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
createRequire(join(root, "only-bare.cjs")).resolve("./only/"),
|
||||
).toThrow();
|
||||
expect(
|
||||
createRequire(join(root, "escape.cjs")).resolve("../outside/"),
|
||||
).toBe(outside);
|
||||
|
||||
@@ -961,6 +961,33 @@ describe("bounded Interface Builder archive decoding", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("decodes an archive whose date the decoder cannot represent", async () => {
|
||||
const root = await createTestTempDirectory("rea-ib-test-");
|
||||
const bundle = join(root, "Example.app");
|
||||
const resources = join(bundle, "Contents", "Resources");
|
||||
await mkdir(resources, { recursive: true });
|
||||
// plutil writes years past 9999 unsigned, which the decoder rejects.
|
||||
await writeFile(
|
||||
join(resources, "Far.nib"),
|
||||
'<?xml version="1.0"?><plist version="1.0"><dict><key>$archiver</key><string>NSKeyedArchiver</string><key>$objects</key><array><string>$null</string><date>11507-08-17T05:20:00Z</date></array><key>$top</key><dict/></dict></plist>',
|
||||
);
|
||||
|
||||
const result = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "f".repeat(64),
|
||||
});
|
||||
|
||||
expect(result.documents).toHaveLength(1);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringMatching(
|
||||
/^Contents\/Resources\/Far\.nib: 1 date value\(s\)/u,
|
||||
),
|
||||
);
|
||||
expect(result.limitations).not.toContainEqual(
|
||||
expect.stringContaining("could not be decoded"),
|
||||
);
|
||||
});
|
||||
|
||||
it.skipIf(!xcodeIbtoolAvailable)(
|
||||
"decodes an Xcode-compiled storyboard NIB and recovers its UI routes",
|
||||
async () => {
|
||||
|
||||
+3
-1
@@ -148,7 +148,9 @@ it("analyzes a plain long-line loader without package metadata", async () => {
|
||||
for (const node of nodes) {
|
||||
expect(node.identity).toMatchObject({
|
||||
strategy: "artifact-local-key",
|
||||
key: node.kind === "endpoint" ? literalEndpointKey("") : "",
|
||||
// A Worker string is relative to the unestablished document base, so
|
||||
// its identity is the retained expression rather than a specifier.
|
||||
key: node.kind === "endpoint" ? literalEndpointKey("") : 'new Worker("")',
|
||||
});
|
||||
expect(node.observations.map(({ label }) => label)).toEqual([null]);
|
||||
}
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ const graphIdentities = async (source: string) => {
|
||||
it.each([
|
||||
"fetch('https://api.example.com/v1/users');",
|
||||
"localStorage.setItem('token', 1);",
|
||||
"new Worker('./worker.js');",
|
||||
"new Worker(new URL('./worker.js', import.meta.url));",
|
||||
"const dep = require('./dep.js'); dep();",
|
||||
"import('./dep.js');",
|
||||
])(
|
||||
|
||||
@@ -168,13 +168,13 @@ it("associates each source-map URL with its edge when aliases share one physical
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a bare source-map file name and worker URL next to the script", async () => {
|
||||
it("resolves a bare source-map file name and module worker URL next to the script", async () => {
|
||||
const root = await createTestTempDirectory("rea-source-map-relative-");
|
||||
await mkdir(join(root, "dist"));
|
||||
await writeFile(
|
||||
join(root, "dist", "app.js"),
|
||||
[
|
||||
'new Worker("worker.js");',
|
||||
'new Worker(new URL("worker.js", import.meta.url));',
|
||||
'navigator.serviceWorker.register("sw.js");',
|
||||
"//# sourceMappingURL=app.js.map",
|
||||
].join("\n"),
|
||||
@@ -228,12 +228,12 @@ it("resolves a bare source-map file name and worker URL next to the script", asy
|
||||
expect(workerEdge?.properties).toMatchObject({
|
||||
resolved_path: "dist/worker.js",
|
||||
});
|
||||
const serviceWorkerEdge = result.graph.edges.find(
|
||||
({ relation, source_node_id }) =>
|
||||
relation === "maps_to" &&
|
||||
nodes.get(source_node_id)?.kind === "service-worker",
|
||||
);
|
||||
expect(serviceWorkerEdge?.properties).toMatchObject({
|
||||
resolved_path: "dist/sw.js",
|
||||
});
|
||||
// A service worker URL is relative to the unestablished document base.
|
||||
expect(
|
||||
result.graph.edges.some(
|
||||
({ relation, source_node_id }) =>
|
||||
relation === "maps_to" &&
|
||||
nodes.get(source_node_id)?.kind === "service-worker",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { build } from "plist";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"built CLI reports a date the decoder cannot represent as unknown",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-keyed-archive-date-");
|
||||
const path = join(root, "archive.plist");
|
||||
// plutil writes years past 9999 unsigned, which the decoder rejects.
|
||||
const xml = build({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: ["$null", "__FAR__", new Date("2020-01-01T00:00:00Z")],
|
||||
$top: { far: { CF$UID: 1 }, near: { CF$UID: 2 } },
|
||||
}).replace(
|
||||
"<string>__FAR__</string>",
|
||||
"<date>11507-08-17T05:20:00Z</date>",
|
||||
);
|
||||
await writeFile(path, xml);
|
||||
const output = await cli.run({
|
||||
arguments: ["inspect-keyed-archive", path, "--json"],
|
||||
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
|
||||
});
|
||||
expect(output.exitCode).toBe(0);
|
||||
const result = keyedArchiveResultSchema.parse(
|
||||
parseEvidence(output.json).normalized_result,
|
||||
);
|
||||
expect(result.objects.map(({ id, value }) => ({ id, value }))).toEqual([
|
||||
{ id: 0, value: "$null" },
|
||||
{ id: 1, value: { $plist_type: "date", iso8601: null } },
|
||||
{
|
||||
id: 2,
|
||||
value: { $plist_type: "date", iso8601: "2020-01-01T00:00:00.000Z" },
|
||||
},
|
||||
]);
|
||||
expect(result.limitations).toContainEqual(
|
||||
expect.stringContaining("1 date value(s)"),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -119,3 +119,67 @@ it("resolves require calls by lexical binding and source mode", async () => {
|
||||
["top-level-var.cjs", "loaded.cjs"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not attribute overwritten require members to Node's resolver", async () => {
|
||||
const root = await createTestTempDirectory("rea-reference-require-member-");
|
||||
const sources: Record<string, string> = {
|
||||
"assigned.cjs":
|
||||
"require.resolve = () => 'custom';\nconsole.log(require.resolve('./loaded.cjs'));",
|
||||
"defined.cjs":
|
||||
"Object.defineProperty(require, 'resolve', { value: () => 'custom' });\nconsole.log(require.resolve('./loaded.cjs'));",
|
||||
"computed.cjs":
|
||||
"const key = 'resolve';\nrequire[key] = () => 'custom';\nconsole.log(require.resolve('./loaded.cjs'));",
|
||||
"control.cjs": "console.log(typeof require.resolve('./loaded.cjs'));",
|
||||
// A member overwrite does not change bare loader calls.
|
||||
"bare-after-overwrite.cjs":
|
||||
"require.resolve = () => 'custom';\nconsole.log(require('./loaded.cjs'));",
|
||||
// A local loader's member write does not touch Node's loader.
|
||||
"local-loader.cjs":
|
||||
"function patch(require) { require.resolve = () => 'custom'; }\npatch({});\nconsole.log(typeof require.resolve('./loaded.cjs'));",
|
||||
// Optional calls and inert wrappers do not let the loader escape.
|
||||
"optional-call.cjs":
|
||||
"require?.('./loaded.cjs');\nconsole.log(typeof (require).resolve('./loaded.cjs'));",
|
||||
"loaded.cjs": "module.exports = 'real';\n",
|
||||
};
|
||||
await Promise.all(
|
||||
Object.entries(sources).map(([path, source]) =>
|
||||
writeFile(join(root, path), source),
|
||||
),
|
||||
);
|
||||
const runNode = promisify(execFile);
|
||||
for (const [path, stdout] of [
|
||||
["assigned.cjs", "custom\n"],
|
||||
["defined.cjs", "custom\n"],
|
||||
["computed.cjs", "custom\n"],
|
||||
["control.cjs", "string\n"],
|
||||
["bare-after-overwrite.cjs", "real\n"],
|
||||
["local-loader.cjs", "string\n"],
|
||||
["optional-call.cjs", "string\n"],
|
||||
] as const)
|
||||
expect(
|
||||
(
|
||||
await runNode(process.execPath, [join(root, path)], {
|
||||
cwd: root,
|
||||
timeout: 5_000,
|
||||
})
|
||||
).stdout,
|
||||
).toBe(stdout);
|
||||
|
||||
const result = await importReferenceSource({
|
||||
root,
|
||||
caller: "reference-require-member-test",
|
||||
policy: { secretPatterns: [] },
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(
|
||||
result.value.relationships
|
||||
.filter(({ kind }) => kind === "requires")
|
||||
.map(({ from_path, to }) => [from_path, to])
|
||||
.sort((left, right) => (left[0] ?? "").localeCompare(right[0] ?? "")),
|
||||
).toEqual([
|
||||
["bare-after-overwrite.cjs", "loaded.cjs"],
|
||||
["control.cjs", "loaded.cjs"],
|
||||
["local-loader.cjs", "loaded.cjs"],
|
||||
["optional-call.cjs", "loaded.cjs"],
|
||||
]);
|
||||
});
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { createWebTextArtifact } from "../../src/domain/webContentArtifact.js";
|
||||
import { analyzeJavaScriptApplication } from "../support/javascriptApplicationScope.js";
|
||||
import { createTestTempDirectory } from "./temporaryDirectory.js";
|
||||
|
||||
export const SOURCE = `const worker = new Worker("./worker.js");\nexport const observed = worker;\n`;
|
||||
export const SOURCE = `const worker = new Worker(new URL("./worker.js", import.meta.url));\nexport const observed = worker;\n`;
|
||||
|
||||
export const applicationFixture = async (): Promise<string> => {
|
||||
const root = await createTestTempDirectory("rea-runtime-reconciliation-");
|
||||
|
||||
Reference in new issue
Block a user