From f3eccb0dfdec7a1a57be734dde73e4eff6c9b832 Mon Sep 17 00:00:00 2001 From: Mason Huang Date: Fri, 3 Jul 2026 23:28:17 +0800 Subject: [PATCH] fix: harden native i18n identifier filtering (#99098) Summary: - The PR replaces native i18n conditional-branch regex filtering with an exported linear ASCII scanner and adds focused coverage. - PR surface: Tests +9, Other +36. Total +45 across 2 files. - Reproducibility: yes. from source inspection: current main routes conditional-branch native literals through the backtracking-prone regex before inventory output. I did not run a timing benchmark against current main. Automerge notes: - No ClawSweeper repair was needed after automerge opt-in. Validation: - ClawSweeper review passed for head 97464c96358d50445861fe325cb1609e68430989. - Required merge gates passed before the squash merge. Prepared head SHA: 97464c96358d50445861fe325cb1609e68430989 Review: https://github.com/openclaw/openclaw/pull/99098#issuecomment-4866733462 Co-authored-by: Mason Huang Approved-by: hxy91819 --- scripts/native-app-i18n.ts | 38 +++++++++++++++++++++++++++- test/scripts/native-app-i18n.test.ts | 9 +++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/scripts/native-app-i18n.ts b/scripts/native-app-i18n.ts index 32b2b1bd5407..621f745f7032 100644 --- a/scripts/native-app-i18n.ts +++ b/scripts/native-app-i18n.ts @@ -170,6 +170,42 @@ const EXCLUDED_FILE_RE = /(?:Tests?|UITests?|Previews?|Testing)\.(?:swift|kt|kts const BUILD_SETTING_RE = /\$\([A-Za-z0-9_.-]+\)/gu; const NATIVE_I18N_LOCALE_SET = new Set(NATIVE_I18N_LOCALES); +function isAsciiLowercaseLetter(character: string): boolean { + return character >= "a" && character <= "z"; +} + +function isAsciiUppercaseLetter(character: string): boolean { + return character >= "A" && character <= "Z"; +} + +function isAsciiAlphaNumeric(character: string): boolean { + return ( + isAsciiLowercaseLetter(character) || + isAsciiUppercaseLetter(character) || + (character >= "0" && character <= "9") + ); +} + +export function isConditionalBranchIdentifier(source: string): boolean { + let index = 0; + while (index < source.length && isAsciiLowercaseLetter(source[index])) { + index += 1; + } + + // Keep this scanner linear: PR-controlled native source passes through CI, + // so a backtracking regex here can become a cheap native-i18n DoS trigger. + if (index === 0 || index >= source.length || !isAsciiUppercaseLetter(source[index])) { + return false; + } + + for (index += 1; index < source.length; index += 1) { + if (!isAsciiAlphaNumeric(source[index])) { + return false; + } + } + return true; +} + function isTranslatableCandidate(source: string, kind: string): boolean { if (BUILD_SETTING_RE.test(source)) { BUILD_SETTING_RE.lastIndex = 0; @@ -183,7 +219,7 @@ function isTranslatableCandidate(source: string, kind: string): boolean { if (!isDirectUiText && (/^[a-z0-9_.:/$-]+$/u.test(source) || /^[A-Z0-9_.:/$-]+$/u.test(source))) { return false; } - if (kind === "conditional-branch" && /^[a-z]+(?:[A-Z][A-Za-z0-9]*)+$/u.test(source)) { + if (kind === "conditional-branch" && isConditionalBranchIdentifier(source)) { return false; } if (/[{}[\]]/u.test(source) && !/(?:\\\(|\$\{)/u.test(source)) { diff --git a/test/scripts/native-app-i18n.test.ts b/test/scripts/native-app-i18n.test.ts index a43ee53a8235..68fe429d0dca 100644 --- a/test/scripts/native-app-i18n.test.ts +++ b/test/scripts/native-app-i18n.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { collectNativeI18nEntries, + isConditionalBranchIdentifier, NATIVE_I18N_LOCALES, parseNativeI18nCommand, syncNativeLocale, @@ -11,6 +12,14 @@ import { import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; describe("native app i18n inventory", () => { + it("detects conditional branch identifiers without regex backtracking", () => { + expect(isConditionalBranchIdentifier("isEnabled")).toBe(true); + expect(isConditionalBranchIdentifier("hasFA2Enabled")).toBe(true); + expect(isConditionalBranchIdentifier("abc123A")).toBe(false); + expect(isConditionalBranchIdentifier("already_lowercase")).toBe(false); + expect(isConditionalBranchIdentifier(`a${"A".repeat(4_096)}!`)).toBe(false); + }); + it("collects stable Android and Apple UI entries", async () => { const entries = await collectNativeI18nEntries(); const surfaces = new Set(entries.map((entry) => entry.surface));