Files
reverse-engineering-anything/src/apktool/ApktoolStringTables.ts
T
morluto d37c346477 fix(apktool): retain observed table counts through output limits
Separate physical string-table facts from per-selection output allocation.
Use one owner for legacy and batched projections, preserving caller labels
and normalized-directory reuse. Retain complete counts and at most one
bounded table prefix for later aliases. A skipped cached alias keeps its
known counts; unexamined tables keep explicit unknown counts.

Remove the provider's duplicated parsing and status branches. Charge the
primary legacy copy before selecting output entries and derive limitation
text from the actual emitted count. Reuse the named request input type and
clarify nullable count descriptions without changing their schema types.

Have the workspace report retained raw-prefix bytes, including a stripped
UTF-8 BOM, instead of reconstructing source cost from decoded text.

Validation: 145 focused checks across 8 files; baseline count control fails
unchanged fd6da7092; check:fast, docs:check and discovery 1556 pass. Twelve
small public CLI decode workflows preserve exact normal-path replies and
request/reply token estimates. Timing variation is noise-level; no speedup,
large-case memory or real Apktool qualification claim. Heavy tests/profiling
remain stopped.

Fixes #4874
2026-10-18 04:19:10 +08:00

253 lines
8.4 KiB
TypeScript

import { join } from "node:path";
import {
AGGREGATE_STRINGS_XML_MAX_BYTES,
MAX_AGGREGATE_STRINGS,
MAX_STRINGS,
androidLocaleDirectorySuffix,
type ApktoolRequest,
type StringTable,
} from "../domain/apktool/apktoolResourceAnalysis.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
parseDecodedStrings,
type DecodedStringEntry,
} from "./ApktoolDecodeOutput.js";
import type { ApktoolWorkspace } from "./ApktoolWorkspace.js";
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
type Selection = Pick<
Extract<ApktoolRequest, { operation: "decode_android_resources" }>["input"],
"include_strings" | "locale" | "locales"
>;
type PresentTable = {
readonly status: "present";
readonly entries: readonly DecodedStringEntry[];
readonly totalCount: number;
readonly unparsedCount: number;
};
type UnavailableTable =
| {
readonly status: "missing";
readonly totalCount: 0;
readonly unparsedCount: 0;
}
| {
readonly status: "oversized";
readonly totalCount: null;
readonly unparsedCount: null;
readonly byteBudget: number;
}
| {
readonly status: "malformed";
readonly totalCount: null;
readonly unparsedCount: null;
readonly message: string;
};
type PhysicalTable = PresentTable | UnavailableTable;
/** One physical read owns parsed counts independently of output allocation. */
const readTable = async (
resources: Pick<ApktoolWorkspace, "readText">,
path: string,
budget: number,
signal?: AbortSignal,
): Promise<{ readonly fact: PhysicalTable; readonly bytes: number }> => {
const source = await resources.readText(path, budget, signal);
if (source.text === null)
return {
bytes: 0,
fact: { status: "missing", totalCount: 0, unparsedCount: 0 },
};
if (source.truncated)
return {
bytes: source.sourceBytes,
fact: {
status: "oversized",
totalCount: null,
unparsedCount: null,
byteBudget: budget,
},
};
const bytes = source.sourceBytes;
const parsed = parseDecodedStrings(source.text);
if (!parsed.ok)
return {
bytes,
fact: {
status: "malformed",
totalCount: null,
unparsedCount: null,
message: parsed.error.message,
},
};
return {
bytes,
fact: {
status: "present",
// Future aliases need counts and at most one table's output, not every
// parsed entry. The complete source was examined before this retention cut.
entries: parsed.value.entries.slice(0, MAX_STRINGS),
totalCount: parsed.value.entries.length,
unparsedCount: parsed.value.unparsedCount,
},
};
};
const unavailableTableLimitation = (
fact: UnavailableTable,
directory: string,
locale: string | null,
input: Selection,
): string => {
if (fact.status === "missing")
return locale === null
? "No default res/values/strings.xml was found in the decoded workspace"
: `No strings.xml was found for locale ${locale}`;
if (fact.status === "oversized")
return input.locales === undefined || locale === null
? `strings.xml exceeds the ${String(fact.byteBudget)}-byte projection budget`
: `strings.xml for locale ${locale} exceeds the ${String(fact.byteBudget)}-byte projection budget`;
return `String table ${JSON.stringify(`res/${directory}/strings.xml`)} could not be projected: ${fact.message}; entry counts are unknown`;
};
export interface ApktoolStringProjection {
readonly strings: readonly DecodedStringEntry[];
readonly tables: readonly StringTable[];
readonly limitations: readonly string[];
readonly coverage: "complete" | "partial";
}
/** Project caller labels from shared physical facts for both request shapes. */
export const projectApktoolStringTables = async (
resources: Pick<ApktoolWorkspace, "readText">,
workspace: string,
input: Selection,
signal?: AbortSignal,
): Promise<ApktoolStringProjection> => {
if (!input.include_strings)
return { strings: [], tables: [], limitations: [], coverage: "complete" };
const primary = input.locale ?? null;
const locales = [...new Set([primary, ...(input.locales ?? [])])];
const physical = new Map<string, PhysicalTable>();
const tables: StringTable[] = [];
const limitations = new Set<string>();
let xmlBytes = 0;
let outputEntries = 0;
let aggregateCapped = false;
for (const [index, locale] of locales.entries()) {
if (signal?.aborted)
throw new AnalysisCancelledError("decode_android_resources", {
cause: signal.reason,
});
const directory =
locale === null
? "values"
: `values-${androidLocaleDirectorySuffix(locale)}`;
const label =
locale === null
? "default res/values/strings.xml"
: `res/${directory}/strings.xml`;
let fact = physical.get(directory);
if (fact === undefined) {
const budget = Math.min(
STRINGS_XML_MAX_BYTES,
AGGREGATE_STRINGS_XML_MAX_BYTES - xmlBytes,
);
if (budget <= 0 || outputEntries >= MAX_AGGREGATE_STRINGS) {
tables.push({
locale,
status: "skipped",
strings: [],
total_count: null,
unparsed_count: null,
coverage: "partial",
});
limitations.add(
`Projection for ${label} skipped: aggregate resource limit reached`,
);
aggregateCapped = true;
continue;
}
const read = await readTable(
resources,
join(workspace, "res", directory, "strings.xml"),
budget,
signal,
);
fact = read.fact;
xmlBytes += read.bytes;
if (fact.status === "oversized" && budget < STRINGS_XML_MAX_BYTES)
aggregateCapped = true;
physical.set(directory, fact);
}
if (fact.status !== "present") {
tables.push({
locale,
status: fact.status,
strings: [],
total_count: fact.totalCount,
unparsed_count: fact.unparsedCount,
coverage: fact.status === "missing" ? "complete" : "partial",
});
limitations.add(
unavailableTableLimitation(fact, directory, locale, input),
);
continue;
}
// Batch output includes the primary strings twice, in the legacy field and
// in string_tables. Charge that representation before selecting entries.
const copies = index === 0 && input.locales !== undefined ? 2 : 1;
const remaining = Math.max(0, MAX_AGGREGATE_STRINGS - outputEntries);
const selected = fact.entries.slice(0, Math.floor(remaining / copies));
outputEntries += selected.length * copies;
const skipped = selected.length === 0 && fact.totalCount > 0;
const limited = selected.length < Math.min(fact.totalCount, MAX_STRINGS);
if (limited) aggregateCapped = true;
tables.push({
locale,
status: skipped ? "skipped" : "present",
strings: selected,
// Output exhaustion cannot erase counts from an already examined table.
total_count: fact.totalCount,
unparsed_count: fact.unparsedCount,
coverage:
selected.length < fact.totalCount || fact.unparsedCount > 0
? "partial"
: "complete",
});
if (skipped)
limitations.add(
`Projection for ${label} skipped: aggregate resource limit reached`,
);
if (fact.unparsedCount > 0)
limitations.add(
input.locales === undefined || locale === null
? `String entries omitted due to unsupported markup or missing names: ${String(fact.unparsedCount)}`
: `String entries omitted in locale ${locale} due to unsupported markup or missing names: ${String(fact.unparsedCount)}`,
);
if (limited && !skipped)
limitations.add(
`Locale ${locale ?? "default"} strings reports ${String(selected.length)} entries due to aggregate strings limit`,
);
else if (fact.totalCount > MAX_STRINGS && !skipped)
limitations.add(
input.locales === undefined || locale === null
? `strings reports the first ${String(selected.length)} of ${String(fact.totalCount)} entries`
: `Locale ${locale} strings reports the first ${String(selected.length)} of ${String(fact.totalCount)} entries`,
);
}
if (aggregateCapped)
limitations.add(
"Aggregate string table projection stopped due to resource limits",
);
return {
strings: tables[0]?.strings ?? [],
tables,
limitations: [...limitations],
coverage: tables.some((table) => table.coverage === "partial")
? "partial"
: "complete",
};
};