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
This commit is contained in:
morluto
2026-10-18 04:13:03 +08:00
parent 9e372c6bc9
commit d37c346477
7 changed files with 551 additions and 361 deletions
+57
View File
@@ -653,6 +653,63 @@ it("reuses normalized directory table for locale aliases without extra reads", a
expect(invocations).toHaveLength(1);
});
it.each([2000, 2001])(
"retains observed alias counts after exhausting the aggregate entry budget (%i entries)",
async (entryCount) => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub({ withGerman: false, withStrings: false })(root).replace(
'if [ "$1" = "d" ]; then',
`if [ "$1" = "d" ]; then
for suffix in pt-rBR de es it; do
mkdir -p "$5/res/values-$suffix"
cp '${join(root, "strings.xml")}' "$5/res/values-$suffix/strings.xml"
done`,
),
);
const entries = Array.from(
{ length: entryCount },
(_, i) => `<string name="s_${String(i)}">value</string>`,
).join("");
await writeFile(
join(root, "strings.xml"),
`<resources>${entries}<string name="styled"><b>unknown</b></string></resources>`,
);
const apk = await apkFixture(root);
const provider = providerOver(command);
try {
const observation = await new ApktoolResourceAnalysisService(
provider,
).execute("decode_android_resources", {
path: apk.path,
include_strings: true,
locale: "pt-BR",
locales: ["de", "es", "it", "pt-rBR"],
});
if (!observation.ok) throw observation.error;
const result = apktoolResultSchemas.decode_android_resources.parse(
observation.value.normalized_result,
);
expect(result.string_tables?.at(-1)).toEqual({
locale: "pt-rBR",
status: "skipped",
strings: [],
total_count: entryCount,
unparsed_count: 1,
coverage: "partial",
});
expect(result.strings).toHaveLength(2000);
expect(
observation.value.limitations.some((note) =>
note.startsWith("Locale pt-rBR strings reports the first"),
),
).toBe(false);
expect(result.coverage).toBe("partial");
} finally {
await provider.close();
}
},
);
it("skips all string tables when include_strings is false despite locales requested", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub({ withGerman: false })(root).replace(
+19 -355
View File
@@ -30,14 +30,8 @@ import {
createAnalysisExecutionEvidence,
type AnalysisExecution,
} from "../application/AnalysisProvider.js";
import {
androidLocaleDirectorySuffix,
AGGREGATE_STRINGS_XML_MAX_BYTES,
MAX_AGGREGATE_STRINGS,
MAX_STRINGS,
type ApktoolRequest,
type StringTable,
} from "../domain/apktool/apktoolResourceAnalysis.js";
import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.js";
import { projectApktoolStringTables } from "./ApktoolStringTables.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
@@ -68,10 +62,7 @@ import {
} from "./ApktoolConfiguration.js";
import {
parseApktoolYml,
parseDecodedStrings,
parseManifestPackage,
type ApktoolStringsDocumentError,
type DecodedStringEntry,
} from "./ApktoolDecodeOutput.js";
/** Stable identity for the Apktool resource-decoding provider. */
@@ -84,7 +75,6 @@ export const APKTOOL_PROVIDER_IDENTITY = {
const DECODE_TIMEOUT_MS = 600_000;
const DECODE_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
const MANIFEST_MAX_BYTES = 512 * 1024;
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
const MAX_DECODED_ENTRIES_VISITED = 100_000;
const STDERR_EXCERPT_BYTES = 512;
@@ -511,12 +501,10 @@ export class ApktoolProvider {
apktool_version: string | null;
},
provider: { id: string; name: string; version: string | null },
input: {
readonly path: string;
readonly include_strings: boolean;
readonly locale?: string | undefined;
readonly locales?: readonly string[] | undefined;
},
input: Extract<
ApktoolRequest,
{ operation: "decode_android_resources" }
>["input"],
clientLimitations: readonly string[],
signal: AbortSignal | undefined,
): Promise<Result<AnalysisExecution, AnalysisError>> {
@@ -591,19 +579,10 @@ export class ApktoolProvider {
manifest === null || manifestRead.truncated
? null
: parseManifestPackage(manifest);
let strings: Array<{ name: string; value: string }>;
let locale: string | null;
let stringTables: StringTable[] | undefined;
const limitations: string[] = [
...clientLimitations,
"Decoding skips smali (--no-src); Java-source recovery belongs to the JADX family",
];
let stringsTruncated = false;
let stringsUnparsedCount = 0;
let stringsTotalCount: number | null = null;
let stringsDocumentFailure: ApktoolStringsDocumentError | undefined;
let stringsMissingLimitation: string | undefined;
const files = await countDecodedFiles(
resources,
workspace,
@@ -612,324 +591,15 @@ export class ApktoolProvider {
);
throwIfCancelled(operation, signal);
let aggregateCapped = false;
let anyTablePartial = false;
if (input.locales === undefined) {
locale = input.locale ?? null;
const stringsDirectory =
input.locale === undefined
? "values"
: `values-${androidLocaleDirectorySuffix(input.locale)}`;
const stringsRead = input.include_strings
? await resources.readText(
join(workspace, "res", stringsDirectory, "strings.xml"),
STRINGS_XML_MAX_BYTES,
signal,
)
: { text: null, truncated: false };
stringsTruncated = stringsRead.truncated;
const stringsXml = stringsRead.truncated ? null : stringsRead.text;
const stringsProjection =
stringsXml === null ? undefined : parseDecodedStrings(stringsXml);
const parsedStrings =
stringsProjection?.ok === true ? stringsProjection.value : undefined;
stringsDocumentFailure =
stringsProjection?.ok === false ? stringsProjection.error : undefined;
strings = parsedStrings?.entries.slice(0, MAX_STRINGS) ?? [];
stringsTotalCount = parsedStrings?.entries.length ?? null;
stringsUnparsedCount = parsedStrings?.unparsedCount ?? 0;
if (input.include_strings && stringsRead.text === null) {
stringsMissingLimitation =
input.locale !== undefined
? `No strings.xml was found for locale ${input.locale}`
: "No default res/values/strings.xml was found in the decoded workspace";
}
if (stringsMissingLimitation !== undefined) {
limitations.push(stringsMissingLimitation);
}
if (stringsDocumentFailure !== undefined) {
limitations.push(
`String table ${JSON.stringify(`res/${stringsDirectory}/strings.xml`)} could not be projected: ${stringsDocumentFailure.message}; entry counts are unknown`,
);
}
if (stringsUnparsedCount > 0) {
limitations.push(
`String entries omitted due to unsupported markup or missing names: ${String(stringsUnparsedCount)}`,
);
}
if (stringsTotalCount !== null && stringsTotalCount > MAX_STRINGS) {
limitations.push(
`strings reports the first ${String(MAX_STRINGS)} of ${String(stringsTotalCount)} entries`,
);
}
if (stringsTruncated) {
limitations.push(
`strings.xml exceeds the ${String(STRINGS_XML_MAX_BYTES)}-byte projection budget`,
);
}
} else {
locale = input.locale ?? null;
stringTables = [];
const requestedLocales: Array<{
readonly locale: string | null;
readonly isPrimary: boolean;
}> = [];
requestedLocales.push({
locale,
isPrimary: true,
});
for (const loc of input.locales) {
if (!requestedLocales.some((r) => r.locale === loc)) {
requestedLocales.push({ locale: loc, isPrimary: false });
}
}
if (!input.include_strings) {
strings = [];
stringTables = [];
} else {
let aggregateXmlBytes = 0;
let aggregateStringsCount = 0;
const tableLimitations: string[] = [];
type PhysicalCached =
| {
readonly status: "missing" | "oversized" | "malformed";
readonly total_count: number | null;
readonly unparsed_count: number | null;
readonly coverage: "complete" | "partial";
readonly limitation: string;
}
| {
readonly status: "present";
readonly entries: readonly DecodedStringEntry[];
readonly unparsed_count: number;
};
const physicalCache = new Map<string, PhysicalCached>();
for (const item of requestedLocales) {
throwIfCancelled(operation, signal);
const loc = item.locale;
const dirName =
loc === null
? "values"
: `values-${androidLocaleDirectorySuffix(loc)}`;
const filePath = join(workspace, "res", dirName, "strings.xml");
const tableLabel =
loc === null
? "default res/values/strings.xml"
: `res/${dirName}/strings.xml`;
let cached = physicalCache.get(dirName);
if (cached === undefined) {
const remainingXmlBudget = Math.max(
0,
AGGREGATE_STRINGS_XML_MAX_BYTES - aggregateXmlBytes,
);
const remainingStringsBudget = Math.max(
0,
MAX_AGGREGATE_STRINGS - aggregateStringsCount,
);
if (remainingXmlBudget <= 0 || remainingStringsBudget <= 0) {
aggregateCapped = true;
stringTables.push({
locale: loc,
status: "skipped",
strings: [],
total_count: null,
unparsed_count: null,
coverage: "partial",
});
tableLimitations.push(
`Projection for ${tableLabel} skipped: aggregate resource limit reached`,
);
continue;
}
const tableBudget = Math.min(
STRINGS_XML_MAX_BYTES,
remainingXmlBudget,
);
const stringsRead = await resources.readText(
filePath,
tableBudget,
signal,
);
if (stringsRead.text !== null) {
if (stringsRead.truncated) {
aggregateXmlBytes += tableBudget;
} else {
aggregateXmlBytes += Buffer.byteLength(
stringsRead.text,
"utf-8",
);
}
}
if (stringsRead.text === null) {
cached = {
status: "missing",
total_count: 0,
unparsed_count: 0,
coverage: "complete",
limitation:
loc !== null
? `No strings.xml was found for locale ${loc}`
: "No default res/values/strings.xml was found in the decoded workspace",
};
physicalCache.set(dirName, cached);
} else if (stringsRead.truncated) {
if (tableBudget < STRINGS_XML_MAX_BYTES) {
aggregateCapped = true;
}
cached = {
status: "oversized",
total_count: null,
unparsed_count: null,
coverage: "partial",
limitation:
loc === null
? `strings.xml exceeds the ${String(tableBudget)}-byte projection budget`
: `strings.xml for locale ${loc} exceeds the ${String(tableBudget)}-byte projection budget`,
};
physicalCache.set(dirName, cached);
} else {
const parsed = parseDecodedStrings(stringsRead.text);
if (!parsed.ok) {
cached = {
status: "malformed",
total_count: null,
unparsed_count: null,
coverage: "partial",
limitation: `String table ${JSON.stringify(`res/${dirName}/strings.xml`)} could not be projected: ${parsed.error.message}; entry counts are unknown`,
};
physicalCache.set(dirName, cached);
} else {
cached = {
status: "present",
entries: parsed.value.entries,
unparsed_count: parsed.value.unparsedCount,
};
physicalCache.set(dirName, cached);
}
}
}
if (cached.status !== "present") {
stringTables.push({
locale: loc,
status: cached.status,
strings: [],
total_count: cached.total_count,
unparsed_count: cached.unparsed_count,
coverage: cached.coverage,
});
if (!tableLimitations.includes(cached.limitation)) {
tableLimitations.push(cached.limitation);
}
continue;
}
const totalCount = cached.entries.length;
const remainingStringsBudget = Math.max(
0,
MAX_AGGREGATE_STRINGS - aggregateStringsCount,
);
const tableLimit = Math.min(MAX_STRINGS, remainingStringsBudget);
if (remainingStringsBudget <= 0) {
aggregateCapped = true;
stringTables.push({
locale: loc,
status: "skipped",
strings: [],
total_count: null,
unparsed_count: null,
coverage: "partial",
});
tableLimitations.push(
`Projection for ${tableLabel} skipped: aggregate resource limit reached`,
);
continue;
}
const bounded = cached.entries.slice(0, tableLimit);
aggregateStringsCount += bounded.length;
if (item.isPrimary) {
// Primary table copy in top-level strings also consumes output entry budget
aggregateStringsCount += bounded.length;
}
if (bounded.length < Math.min(totalCount, MAX_STRINGS)) {
aggregateCapped = true;
}
const tableCoverage =
totalCount > MAX_STRINGS ||
bounded.length < totalCount ||
cached.unparsed_count > 0
? "partial"
: "complete";
stringTables.push({
locale: loc,
status: "present",
strings: bounded,
total_count: totalCount,
unparsed_count: cached.unparsed_count,
coverage: tableCoverage,
});
if (cached.unparsed_count > 0) {
tableLimitations.push(
loc === null
? `String entries omitted due to unsupported markup or missing names: ${String(cached.unparsed_count)}`
: `String entries omitted in locale ${loc} due to unsupported markup or missing names: ${String(cached.unparsed_count)}`,
);
}
if (totalCount > MAX_STRINGS) {
tableLimitations.push(
loc === null
? `strings reports the first ${String(MAX_STRINGS)} of ${String(totalCount)} entries`
: `Locale ${loc} strings reports the first ${String(MAX_STRINGS)} of ${String(totalCount)} entries`,
);
} else if (bounded.length < totalCount) {
tableLimitations.push(
`Locale ${loc ?? "default"} strings reports ${String(bounded.length)} entries due to aggregate strings limit`,
);
}
}
limitations.push(...tableLimitations);
const primaryTable = stringTables.find((t) => t.locale === locale);
strings = primaryTable?.strings ?? [];
}
anyTablePartial = stringTables.some(
(t) =>
t.coverage === "partial" ||
t.status === "oversized" ||
t.status === "malformed" ||
t.status === "skipped",
);
}
const projectedStrings = await projectApktoolStringTables(
resources,
workspace,
input,
signal,
);
limitations.push(...projectedStrings.limitations);
const coverage =
(input.locales === undefined
? (stringsTotalCount !== null && stringsTotalCount > MAX_STRINGS) ||
stringsTruncated ||
stringsUnparsedCount > 0 ||
stringsDocumentFailure !== undefined
: anyTablePartial || aggregateCapped) ||
projectedStrings.coverage === "partial" ||
manifestRead.truncated ||
files.capped ||
files.unreadableStringsTables.length > 0
@@ -956,12 +626,6 @@ export class ApktoolProvider {
`Decoded workspace enumeration stopped after ${String(MAX_DECODED_ENTRIES_VISITED)} entries; decoded_file_count and locales may be partial`,
);
}
if (aggregateCapped) {
limitations.push(
"Aggregate string table projection stopped due to resource limits",
);
}
result = ok(
createAnalysisExecution(
{
@@ -975,12 +639,12 @@ export class ApktoolProvider {
package_name: packageName,
},
manifest,
strings,
locale,
strings: projectedStrings.strings,
locale: input.locale ?? null,
locales: files.locales,
...(stringTables !== undefined
? { string_tables: stringTables }
: {}),
...(input.locales === undefined
? {}
: { string_tables: projectedStrings.tables }),
decoded_file_count: files.count,
coverage,
},
+181
View File
@@ -0,0 +1,181 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
MAX_AGGREGATE_STRINGS,
MAX_STRINGS,
} from "../domain/apktool/apktoolResourceAnalysis.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { projectApktoolStringTables } from "./ApktoolStringTables.js";
const xml = (count: number) =>
`<resources>${Array.from({ length: count }, (_, index) => `<string name="s_${String(index)}">value</string>`).join("")}</resources>`;
const textRead = (text: string) => ({
text,
truncated: false,
sourceBytes: Buffer.byteLength(text, "utf8"),
});
describe("physical Apktool string facts", () => {
it("reads one physical table for two caller-selected aliases", async () => {
const paths: string[] = [];
const result = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
return textRead(xml(1));
},
},
"decoded",
{ include_strings: true, locale: "pt-BR", locales: ["pt-rBR"] },
);
expect(paths).toEqual([
join("decoded", "res", "values-pt-rBR", "strings.xml"),
]);
expect(result.tables.map((table) => table.locale)).toEqual([
"pt-BR",
"pt-rBR",
]);
expect(result.tables.map((table) => table.total_count)).toEqual([1, 1]);
});
it("counts complete source entries beyond the retained table prefix", async () => {
const result = await projectApktoolStringTables(
{
async readText() {
return textRead(
xml(MAX_STRINGS + 1).replace(
"</resources>",
'<string name="styled"><b>omitted</b></string></resources>',
),
);
},
},
"decoded",
{ include_strings: true },
);
expect(result.tables[0]).toMatchObject({
total_count: MAX_STRINGS + 1,
unparsed_count: 1,
coverage: "partial",
});
expect(result.strings).toHaveLength(MAX_STRINGS);
expect(result.limitations).toContain(
`strings reports the first ${String(MAX_STRINGS)} of ${String(MAX_STRINGS + 1)} entries`,
);
});
it("does not read unexamined tables after output exhaustion", async () => {
const paths: string[] = [];
const result = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
if (path.includes("values-fr"))
throw new Error("Unexamined table was read");
return textRead(xml(MAX_STRINGS));
},
},
"decoded",
{ include_strings: true, locales: ["de", "es", "it", "fr"] },
);
expect(paths).toHaveLength(4);
expect(result.tables.at(-1)).toEqual({
locale: "fr",
status: "skipped",
strings: [],
total_count: null,
unparsed_count: null,
coverage: "partial",
});
expect(
result.strings.length +
result.tables.reduce((count, table) => count + table.strings.length, 0),
).toBe(MAX_AGGREGATE_STRINGS);
});
it("keeps a known empty alias present after the output budget is spent", async () => {
const paths: string[] = [];
const result = await projectApktoolStringTables(
{
async readText(path) {
paths.push(path);
return textRead(
xml(path.includes("values-pt-rBR") ? 0 : MAX_STRINGS),
);
},
},
"decoded",
{
include_strings: true,
locale: "pt-BR",
locales: ["de", "es", "it", "fr", "en", "pt-rBR"],
},
);
expect(paths.filter((path) => path.includes("values-pt-rBR"))).toHaveLength(
1,
);
expect(result.tables.at(-1)).toEqual({
locale: "pt-rBR",
status: "present",
strings: [],
total_count: 0,
unparsed_count: 0,
coverage: "complete",
});
expect(result.coverage).toBe("complete");
});
});
describe("Apktool string projection admission", () => {
it("honors omitted strings without reading any selected table", async () => {
const result = await projectApktoolStringTables(
{
async readText() {
throw new Error("Unexpected read");
},
},
"decoded",
{ include_strings: false, locale: "de", locales: ["fr"] },
);
expect(result).toEqual({
strings: [],
tables: [],
limitations: [],
coverage: "complete",
});
});
it("preserves the exact physical read failure", async () => {
const cause = new Error("Physical read and close failed");
await expect(
projectApktoolStringTables(
{
async readText() {
throw cause;
},
},
"decoded",
{ include_strings: true },
),
).rejects.toBe(cause);
});
it("preserves opaque cancellation before any table read", async () => {
const reason = { selected: "caller" };
const pending = projectApktoolStringTables(
{
async readText() {
throw new Error("Unexpected read");
},
},
"decoded",
{ include_strings: true },
AbortSignal.abort(reason),
);
await expect(pending).rejects.toBeInstanceOf(AnalysisCancelledError);
await expect(pending).rejects.toHaveProperty("cause", reason);
});
});
+252
View File
@@ -0,0 +1,252 @@
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",
};
};
+30 -1
View File
@@ -149,7 +149,7 @@ it("records cancellation observed during a successful output descriptor close",
expect(failure.fileCancellation.reason).toBe(reason);
expect(failure.fileCancellation.outcome).toEqual({
kind: "completed",
value: { text: "<resources/>", truncated: false },
value: { text: "<resources/>", truncated: false, sourceBytes: 12 },
});
} finally {
await workspace.close();
@@ -358,3 +358,32 @@ it("preserves a directory inspection failure instead of treating its locale tabl
await commands.close();
}
});
it("reports raw retained-prefix bytes when UTF-8 decoding removes a BOM", async () => {
const root = await PrivateRuntimeRoot.create({
prefix: "rea-apktool-source-bytes-",
});
const commands = new ApktoolCommandCleanup();
const workspace = new ApktoolWorkspace(root, commands);
const output = join(root.path, "strings.xml");
const bytes = Buffer.from("\uFEFF<resources/>", "utf8");
try {
await fs.writeFile(output, bytes);
await expect(workspace.readText(output, 100)).resolves.toEqual({
text: "<resources/>",
truncated: false,
sourceBytes: bytes.length,
});
await expect(workspace.readText(output, 5)).resolves.toEqual({
text: "<r",
truncated: true,
sourceBytes: 5,
});
await expect(
workspace.readText(join(root.path, "missing.xml"), 100),
).resolves.toEqual({ text: null, truncated: false, sourceBytes: 0 });
} finally {
await workspace.close();
await commands.close();
}
});
+10 -3
View File
@@ -146,7 +146,12 @@ export class ApktoolWorkspace {
path: string,
budget: number,
signal?: AbortSignal,
): Promise<{ text: string | null; truncated: boolean }> {
): Promise<{
text: string | null;
truncated: boolean;
/** Raw bytes represented by the retained prefix, before UTF-8 decoding. */
sourceBytes: number;
}> {
let admitted = false;
try {
return await withArtifactRegularFile(
@@ -175,13 +180,15 @@ export class ApktoolWorkspace {
`Apktool output changed while being read: ${path}`,
);
const truncated = length > budget;
const sourceBytes = Math.min(length, budget);
try {
return {
text: new TextDecoder("utf-8", { fatal: true }).decode(
bytes.subarray(0, Math.min(length, budget)),
bytes.subarray(0, sourceBytes),
{ stream: truncated },
),
truncated,
sourceBytes,
};
} catch (cause) {
throw new AnalysisProtocolError(
@@ -198,7 +205,7 @@ export class ApktoolWorkspace {
"code" in cause &&
cause.code === "ENOENT"
)
return { text: null, truncated: false };
return { text: null, truncated: false, sourceBytes: 0 };
throw cause;
}
}
@@ -138,7 +138,7 @@ export const stringTableSchema = z.strictObject({
.nonnegative()
.nullable()
.describe(
"Total valid string entries before per-table cap, or null if unmeasured/skipped/malformed",
"Total valid string entries before output limits, or null if the table was not examined or was malformed",
),
unparsed_count: z
.number()
@@ -146,7 +146,7 @@ export const stringTableSchema = z.strictObject({
.nonnegative()
.nullable()
.describe(
"Entries skipped due to nested markup or missing attributes, or null if unmeasured/skipped/malformed",
"Entries skipped due to nested markup or missing attributes, or null if the table was not examined or was malformed",
),
coverage: z.enum(["complete", "partial"]),
});