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 97464c9635.
- Required merge gates passed before the squash merge.

Prepared head SHA: 97464c9635
Review: https://github.com/openclaw/openclaw/pull/99098#issuecomment-4866733462

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Approved-by: hxy91819
This commit is contained in:
Mason Huang
2026-07-03 23:28:17 +08:00
committed by GitHub
parent 235a5c4d74
commit f3eccb0dfd
2 changed files with 46 additions and 1 deletions
+37 -1
View File
@@ -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<string>(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)) {
+9
View File
@@ -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));