mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
03f17cf41d
Apply caller-selected R8/ProGuard mappings to shared Android inspections, preserving raw identities, exact verification, ambiguity and provenance. Add retained summaries and bounded/cached parsing, with measured real R8/Conscrypt workflows and review fixes. Fixes #6329. Co-authored-by: 555aaditya <107977627+555aaditya@users.noreply.github.com>
314 lines
11 KiB
JavaScript
314 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
// Engine-specific diagnostic; run separately from expensive repository checks.
|
|
// Example (parent chooses an allowed CPU):
|
|
// taskset -c CPU node --expose-gc --max-old-space-size=256 \
|
|
// tests/support/analysis-efficiency/android-mapping-retention.mjs --repo /absolute/caller/selected/worktree
|
|
// No providers, target execution, repository writes, or subprocesses are used.
|
|
import assert from "node:assert/strict";
|
|
import { randomBytes, createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
import { setImmediate as immediate } from "node:timers/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
import v8 from "node:v8";
|
|
|
|
const argv = process.argv.slice(2);
|
|
assert.equal(
|
|
argv.length,
|
|
2,
|
|
"usage: --repo /absolute/caller/selected/worktree",
|
|
);
|
|
assert.equal(argv[0], "--repo");
|
|
const selectedRepo = argv[1];
|
|
assert.ok(
|
|
selectedRepo?.startsWith("/"),
|
|
"repo must be explicitly selected and absolute",
|
|
);
|
|
assert.equal(typeof global.gc, "function", "run node --expose-gc");
|
|
assert.ok(
|
|
process.execArgv.includes("--max-old-space-size=256"),
|
|
"use --max-old-space-size=256; this is an old-space bound, not an RSS bound",
|
|
);
|
|
|
|
const COUNT = 4;
|
|
const INPUT_BYTES = 10 * 1024 * 1024;
|
|
const paths = {
|
|
parser: resolve(selectedRepo, "dist/domain/android/proguardMapping.js"),
|
|
evidence: resolve(selectedRepo, "dist/domain/evidence.js"),
|
|
ledger: resolve(
|
|
selectedRepo,
|
|
"dist/application/investigation/EvidenceLedger.js",
|
|
),
|
|
};
|
|
const fingerprints = {};
|
|
for (const [name, path] of Object.entries(paths)) {
|
|
const bytes = await readFile(path);
|
|
fingerprints[name] = {
|
|
path,
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
};
|
|
}
|
|
const { parseProguardMapping, resolveProguardClass, resolveProguardMethod } =
|
|
await import(pathToFileURL(paths.parser).href);
|
|
const { createEvidence } = await import(pathToFileURL(paths.evidence).href);
|
|
const { EvidenceLedger } = await import(pathToFileURL(paths.ledger).href);
|
|
|
|
// This explicit UTF-16 byte roundtrip owns new string storage. Concatenation,
|
|
// slice(0), substring, template literals and structuredClone are not the control.
|
|
function detach(value) {
|
|
if (typeof value === "string")
|
|
return Buffer.from(value, "utf16le").toString("utf16le");
|
|
if (Array.isArray(value)) return value.map(detach);
|
|
if (value !== null && typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, child]) => [key, detach(child)]),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function makeInput(paddingBytes, nonce) {
|
|
// Separate inputs have different valid names AND different ignored content;
|
|
// no shared giant literal, interned name, or same-input deduplication oracle.
|
|
const pattern = Buffer.from(
|
|
`# ignored_${nonce}_${"q".repeat(4000)}\n`,
|
|
"ascii",
|
|
);
|
|
const padding = Buffer.alloc(paddingBytes);
|
|
padding.fill(pattern);
|
|
if (padding.length > 0) padding[padding.length - 1] = 10;
|
|
const originalClass = `fixture.OriginalClass_${nonce}`;
|
|
const residualClass = `ResidualClass_${nonce}`;
|
|
const originalMethod = `originalMethod_${nonce}`;
|
|
const residualMethod = `residualMethod_${nonce}`;
|
|
const suffix = Buffer.from(
|
|
`${originalClass} -> ${residualClass}:\n` +
|
|
` int ${originalMethod}() -> ${residualMethod}\n`,
|
|
"ascii",
|
|
);
|
|
// One decoded flat source, as in a file-buffer decode. Buffer storage itself
|
|
// is not retained; report external/arrayBuffers separately from heapUsed.
|
|
return {
|
|
text: Buffer.concat([padding, suffix]).toString("utf8"),
|
|
originalClass,
|
|
residualClass,
|
|
originalMethod,
|
|
residualMethod,
|
|
};
|
|
}
|
|
|
|
// All text, index, query and assertion temporaries die on returning this frame.
|
|
function selectedClaim(selection, detached, paddingBytes = INPUT_BYTES) {
|
|
const nonce = randomBytes(20).toString("hex");
|
|
const input = makeInput(paddingBytes, nonce);
|
|
const parsed = parseProguardMapping(input.text);
|
|
assert.equal(
|
|
parsed.ok,
|
|
true,
|
|
`parse failed: ${JSON.stringify(parsed.error)}`,
|
|
);
|
|
assert.deepEqual(parsed.value.records, {
|
|
classes: 1,
|
|
fields: 0,
|
|
methods: 1,
|
|
malformed: 0,
|
|
});
|
|
const classClaim = resolveProguardClass(parsed.value, input.residualClass);
|
|
const methodClaim = resolveProguardMethod(parsed.value, {
|
|
className: input.residualClass,
|
|
name: input.residualMethod,
|
|
descriptor: "()I",
|
|
});
|
|
assert.deepEqual(classClaim, {
|
|
status: "mapped",
|
|
verified_by: "class",
|
|
mapped: { name: input.originalClass },
|
|
});
|
|
assert.deepEqual(methodClaim, {
|
|
status: "mapped",
|
|
verified_by: "descriptor",
|
|
mapped: {
|
|
class: input.originalClass,
|
|
name: input.originalMethod,
|
|
return_type: "int",
|
|
parameter_types: [],
|
|
},
|
|
});
|
|
const selected =
|
|
selection === "class"
|
|
? classClaim
|
|
: selection === "method"
|
|
? methodClaim
|
|
: {
|
|
class: classClaim,
|
|
method: methodClaim,
|
|
};
|
|
const result = detached ? detach(selected) : selected;
|
|
if (detached)
|
|
assert.deepEqual(result, selected, "detachment must preserve exact claims");
|
|
return result;
|
|
}
|
|
|
|
const PROVIDER = {
|
|
id: "string-retention-probe",
|
|
name: "Local GC probe",
|
|
version: "1",
|
|
};
|
|
function recordSelected(holder, detached, paddingBytes = INPUT_BYTES) {
|
|
const claim = selectedClaim("both", detached, paddingBytes);
|
|
// Real production createEvidence -> immutable snapshot -> real ledger record.
|
|
// There is no JSON stringify/parse inserted as an artificial detachment.
|
|
const evidence = createEvidence(
|
|
undefined,
|
|
PROVIDER,
|
|
{
|
|
operation: "string-retention-probe",
|
|
parameters: {},
|
|
result: claim,
|
|
confidence: "derived",
|
|
},
|
|
{ immutable: true },
|
|
);
|
|
assert.ok(Object.isFrozen(evidence));
|
|
assert.ok(Object.isFrozen(evidence.normalized_result));
|
|
assert.deepEqual(evidence.normalized_result, claim);
|
|
assert.deepEqual(holder.ledger.record(evidence), {
|
|
ok: true,
|
|
value: "added",
|
|
});
|
|
}
|
|
|
|
function clearRegexLastMatch() {
|
|
// V8 legacy RegExp statics must not leave the last source as a separate root.
|
|
/^(x)$/.exec("x");
|
|
assert.equal(RegExp.input, "x");
|
|
}
|
|
|
|
async function settledMemory() {
|
|
clearRegexLastMatch();
|
|
// Cross an event-loop checkpoint so completed per-input frames and buffers
|
|
// are dead, then collect repeatedly. No arbitrary wall-clock sleep ordering.
|
|
for (let pass = 0; pass < 4; pass++) {
|
|
await immediate();
|
|
global.gc();
|
|
}
|
|
const { heapUsed, external, arrayBuffers, rss } = process.memoryUsage();
|
|
return { heapUsed, external, arrayBuffers, rss };
|
|
}
|
|
|
|
function delta(after, before) {
|
|
return Object.fromEntries(
|
|
Object.keys(before).map((key) => [key, after[key] - before[key]]),
|
|
);
|
|
}
|
|
|
|
async function phase(label, selection, detached, ledger) {
|
|
const baseline = await settledMemory();
|
|
let holder = ledger ? { ledger: new EvidenceLedger() } : { claims: [] };
|
|
const increments = [];
|
|
for (let index = 0; index < COUNT; index++) {
|
|
if (ledger) recordSelected(holder, detached);
|
|
else holder.claims.push(selectedClaim(selection, detached));
|
|
const current = await settledMemory();
|
|
increments.push({ selected: index + 1, ...delta(current, baseline) });
|
|
}
|
|
if (ledger)
|
|
assert.equal(holder.ledger.immutableBundle().records.length, COUNT);
|
|
else assert.equal(holder.claims.length, COUNT);
|
|
const retained = await settledMemory();
|
|
if (ledger) holder.ledger.clear();
|
|
else holder.claims.length = 0;
|
|
holder = undefined;
|
|
const released = await settledMemory();
|
|
return {
|
|
label,
|
|
selected_packets: COUNT,
|
|
resolved_claims_per_packet: ledger ? 2 : 1,
|
|
ignored_bytes_per_input: INPUT_BYTES,
|
|
baseline,
|
|
increments,
|
|
retained,
|
|
released,
|
|
retained_delta: delta(retained, baseline),
|
|
released_delta: delta(released, baseline),
|
|
released_from_retained: delta(released, retained),
|
|
};
|
|
}
|
|
|
|
// Warm every exact operation before measuring, using only tiny unique inputs.
|
|
function warm() {
|
|
selectedClaim("class", false, 0);
|
|
selectedClaim("method", true, 0);
|
|
const holder = { ledger: new EvidenceLedger() };
|
|
recordSelected(holder, false, 0);
|
|
recordSelected(holder, true, 0);
|
|
holder.ledger.clear();
|
|
}
|
|
warm();
|
|
await settledMemory();
|
|
console.log(
|
|
JSON.stringify({
|
|
kind: "metadata",
|
|
node: process.version,
|
|
v8: process.versions.v8,
|
|
execArgv: process.execArgv,
|
|
selected_repo: selectedRepo,
|
|
fingerprints,
|
|
heap_size_limit: v8.getHeapStatistics().heap_size_limit,
|
|
note: "Engine-dependent heapUsed experiment; old-space cap is not a process-family/RSS guarantee. No universal string-backing claim follows.",
|
|
}),
|
|
);
|
|
|
|
const results = [];
|
|
for (const [label, selection, detached, ledger] of [
|
|
["bare-class", "class", false, false],
|
|
["detached-class-control", "class", true, false],
|
|
["bare-method", "method", false, false],
|
|
["detached-method-control", "method", true, false],
|
|
["real-sealed-evidence-ledger", "both", false, true],
|
|
["detached-real-sealed-evidence-ledger-control", "both", true, true],
|
|
]) {
|
|
const result = await phase(label, selection, detached, ledger);
|
|
results.push(result);
|
|
console.log(JSON.stringify({ kind: "phase", ...result }));
|
|
}
|
|
const byLabel = new Map(results.map((result) => [result.label, result]));
|
|
console.log(
|
|
JSON.stringify({
|
|
kind: "comparisons",
|
|
expected_if_four_full_one_byte_sources_remain: COUNT * INPUT_BYTES,
|
|
direct_minus_detached_heap_bytes: Object.fromEntries(
|
|
[
|
|
["class", "bare-class", "detached-class-control"],
|
|
["method", "bare-method", "detached-method-control"],
|
|
[
|
|
"ledger",
|
|
"real-sealed-evidence-ledger",
|
|
"detached-real-sealed-evidence-ledger-control",
|
|
],
|
|
].map(([name, direct, control]) => [
|
|
name,
|
|
byLabel.get(direct).retained_delta.heapUsed -
|
|
byLabel.get(control).retained_delta.heapUsed,
|
|
]),
|
|
),
|
|
direct_minus_detached_external_bytes: Object.fromEntries(
|
|
[
|
|
["class", "bare-class", "detached-class-control"],
|
|
["method", "bare-method", "detached-method-control"],
|
|
[
|
|
"ledger",
|
|
"real-sealed-evidence-ledger",
|
|
"detached-real-sealed-evidence-ledger-control",
|
|
],
|
|
].map(([name, direct, control]) => [
|
|
name,
|
|
byLabel.get(direct).retained_delta.external -
|
|
byLabel.get(control).retained_delta.external,
|
|
]),
|
|
),
|
|
interpretation:
|
|
"Require growth near one complete source per selected packet, substantially smaller detached controls, AND corresponding release-phase drops. If bare values retain but real sealed ledger does not, the ledger hypothesis is disproven here. If neither does, do not propose a detachment fix. Unexpected residual growth or an inconclusive contrast needs investigation, not an assertion of leakage.",
|
|
}),
|
|
);
|