diff --git a/docs/apktool-resource-analysis.md b/docs/apktool-resource-analysis.md index d894e7808c..484dafcf06 100644 --- a/docs/apktool-resource-analysis.md +++ b/docs/apktool-resource-analysis.md @@ -65,6 +65,9 @@ select Android's `values-pt-rBR`; Android qualifiers such as `pt-rBR` and default table is bounded to 2000 entries with partial coverage when more exist. `locales` lists observed regular `strings.xml` tables whose exact `values-` directory is selectable by the request's locale grammar. + Locale suffixes allow up to 23 ASCII characters, derived from Apktool's + language, script, region, and variant fields; for example, + `b+sr+Latn+RS+REVISED` selects that exact table. Other table directories, including combined qualifiers such as `values-en-rUS-v21` and `values-mcc310-fr`, are named in limitations with their source spelling. They are not advertised as selectable locales; diff --git a/src/apktool/ApktoolProvider.test.ts b/src/apktool/ApktoolProvider.test.ts index 0c0044f4b0..7532e87ddf 100644 --- a/src/apktool/ApktoolProvider.test.ts +++ b/src/apktool/ApktoolProvider.test.ts @@ -928,16 +928,30 @@ it("discloses qualified and unfamiliar strings tables while preserving exact loc "night", "v21", "pt-BR", - "b+sr+Latn+RS+REVISED", "unknown_CONFIGURATION", ]; const { command, root } = await writeApktoolStub( - decodeStub({ extraValuesDirectories: [...tables, "fr", "b+sr+Latn"] }), + decodeStub({ + extraValuesDirectories: [ + ...tables, + "fr", + "b+sr+Latn", + "b+sr+Latn+RS+REVISED", + "b+fil+Latn+419+ABCDEFGH", + ], + }), ); const apk = await apkFixture(root); const provider = providerOver(command); try { - for (const locale of [undefined, "fr", "b+sr+Latn", "en-rUS"] as const) { + for (const locale of [ + undefined, + "fr", + "b+sr+Latn", + "b+sr+Latn+RS+REVISED", + "b+fil+Latn+419+ABCDEFGH", + "en-rUS", + ] as const) { const result = await provider.execute({ operation: "decode_android_resources", input: { @@ -948,7 +962,13 @@ it("discloses qualified and unfamiliar strings tables while preserving exact loc }); if (!result.ok) throw result.error; expect(result.value.result).toMatchObject({ - locales: ["b+sr+Latn", "de", "fr"], + locales: [ + "b+fil+Latn+419+ABCDEFGH", + "b+sr+Latn", + "b+sr+Latn+RS+REVISED", + "de", + "fr", + ], locale: locale ?? null, coverage: "complete", }); diff --git a/src/domain/apktool/apktoolResourceAnalysis.test.ts b/src/domain/apktool/apktoolResourceAnalysis.test.ts index 63b205fb33..20acd6df8b 100644 --- a/src/domain/apktool/apktoolResourceAnalysis.test.ts +++ b/src/domain/apktool/apktoolResourceAnalysis.test.ts @@ -1,18 +1,24 @@ import { expect, it } from "vitest"; import { androidLocaleDirectorySuffix, + androidLocaleQualifierSchema, classifyAndroidStringsTable, } from "./apktoolResourceAnalysis.js"; -it.each(["de", "pt-rBR", "en-r419", "b+sr+Latn", "b+zh+Hans+CN"])( - "recognizes the exact selectable strings table values-%s", - (suffix) => { - expect(classifyAndroidStringsTable(suffix)).toEqual({ - kind: "locale", - locale: suffix, - }); - }, -); +it.each([ + "de", + "pt-rBR", + "en-r419", + "b+sr+Latn", + "b+zh+Hans+CN", + "b+sr+Latn+RS+REVISED", + "b+fil+Latn+419+ABCDEFGH", +])("recognizes the exact selectable strings table values-%s", (suffix) => { + expect(classifyAndroidStringsTable(suffix)).toEqual({ + kind: "locale", + locale: suffix, + }); +}); it.each([ "en-rUS-v21", @@ -25,7 +31,6 @@ it.each([ "v21", "pt-BR", "en-419", - "b+sr+Latn+RS+REVISED", "unknown_CONFIGURATION", "", ])( @@ -49,3 +54,20 @@ it.each([ expect(androidLocaleDirectorySuffix(locale)).toBe(suffix); }, ); + +it.each([ + "b+fil+Latn+419+ABCDEFGH+US", + "b+sr+Latn+RS+REVISED-v21", + "../values-fr", + "car", + "night", +])( + "refuses suffixes outside the bounded locale selection contract: %s", + (suffix) => { + expect(androidLocaleQualifierSchema.safeParse(suffix).success).toBe(false); + expect(classifyAndroidStringsTable(suffix)).toEqual({ + kind: "other", + suffix, + }); + }, +); diff --git a/src/domain/apktool/apktoolResourceAnalysis.ts b/src/domain/apktool/apktoolResourceAnalysis.ts index fe1dd52f55..5fa7da6feb 100644 --- a/src/domain/apktool/apktoolResourceAnalysis.ts +++ b/src/domain/apktool/apktoolResourceAnalysis.ts @@ -14,10 +14,15 @@ const ANDROID_NON_LOCALE_QUALIFIERS = new Set([ "notnight", ]); +// Apktool emits b+language[+script][+region][+variant] from ARSC fields: +// language/region expand to at most 3 ASCII characters, script has 4 bytes, +// and variant has 8 bytes (BinaryResourceParser / ResConfig). +const MAX_ANDROID_LOCALE_SUFFIX = 2 + 3 + (1 + 4) + (1 + 3) + (1 + 8); + const localeQualifierSchema = z .string() .min(2) - .max(16) + .max(MAX_ANDROID_LOCALE_SUFFIX) .regex( /^(?:[a-z]{2,3}(?:-(?:r?[A-Z]{2}|r?[0-9]{3}))?|b\+[a-z]{2,3}(?:\+[A-Za-z0-9]{2,8})*)$/u, "Locale must look like de, pt-BR, pt-rBR, or b+zh+Hans+CN", diff --git a/tests/boundary/cli/apktoolLocaleOption.test.ts b/tests/boundary/cli/apktoolLocaleOption.test.ts index 4bc5966952..a0fe75d46b 100644 --- a/tests/boundary/cli/apktoolLocaleOption.test.ts +++ b/tests/boundary/cli/apktoolLocaleOption.test.ts @@ -56,6 +56,8 @@ const OWNER_ADMITTED_LOCALES = [ "zh-rCN", "en-419", "b+zh+Hans+CN", + "b+sr+Latn+RS+REVISED", + "b+fil+Latn+419+ABCDEFGH", ] as const; it("forwards every locale the apktool input owner admits", async () => { diff --git a/tests/boundary/mcp/apktoolMcp.test.ts b/tests/boundary/mcp/apktoolMcp.test.ts index 25774fe714..e937c0d5bf 100644 --- a/tests/boundary/mcp/apktoolMcp.test.ts +++ b/tests/boundary/mcp/apktoolMcp.test.ts @@ -44,12 +44,15 @@ const results: Readonly> = { }; it("advertises exact valid Apktool schemas and records inline evidence through MCP", async () => { + const requestedLocales: (string | undefined)[] = []; const provider: ApktoolResourceAnalysisPort = { async inspectAvailability() { return { status: "available", code: null, reason: null, diagnostics: {} }; }, async close() {}, async execute(request) { + if (request.operation === "decode_android_resources") + requestedLocales.push(request.input.locale); return ok(createAnalysisExecution(results[request.operation], identity)); }, }; @@ -77,6 +80,24 @@ it("advertises exact valid Apktool schemas and records inline evidence through M for (const name of operationNames) expect(names, name).toContain(name); assertAdvertisedMcpContracts(operationNames, tools); + const localeSchema = tools.find( + (tool) => tool.name === "decode_android_resources", + )?.inputSchema.properties?.locale; + expect(localeSchema).toMatchObject({ maxLength: 23 }); + for (const locale of ["b+sr+Latn+RS+REVISED", "b+fil+Latn+419+ABCDEFGH"]) { + const reply = await client.callTool({ + name: "decode_android_resources", + arguments: { path: "/targets/probe.apk", locale }, + }); + expect(reply.isError).not.toBe(true); + expect(parseEvidence(reply.structuredContent).parameters).toMatchObject({ + locale, + }); + } + expect(requestedLocales).toEqual([ + "b+sr+Latn+RS+REVISED", + "b+fil+Latn+419+ABCDEFGH", + ]); const decoded = await client.callTool({ name: "decode_android_resources",