Skip to content

Commit a6c04d2

Browse files
matz3RandomByte
andauthored
[FIX] manifestEnhancer: Only use valid files for supportedLocales (#1080)
This fixes two problems that could have occurred: A properties file with an invalid locale was still taken into the list of supported locales, which then caused a runtime exception in the ResourceBundle as it validates the input. Another problem was that properties files could have a valid name according to BCP47, but the file won't be ever requested with that name. This is due to the fact that the ResourceBundle does use the legacy Java locale format (using underscores instead of dashes) for the request URL. In both cases, the properties file is now ignored and no entry for the supportedLocales is created. Only locales that are valid according to the legacy Java locale format are considered. However, there is one special case: sr_Latn is also requested by the UI5 runtime, although it contains a BCP47 script, which is not valid according to the legacy Java locale format. --------- Co-authored-by: Merlin Beutlberger <m.beutlberger@sap.com>
1 parent 4245546 commit a6c04d2

File tree

8 files changed

+251
-19
lines changed

8 files changed

+251
-19
lines changed

lib/processors/manifestEnhancer.js

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,58 @@ const log = getLogger("builder:processors:manifestEnhancer");
77

88
const APP_DESCRIPTOR_V22 = new Version("1.21.0");
99

10+
/*
11+
* Matches a legacy Java locale string, which is the format used by the UI5 Runtime (ResourceBundle)
12+
* to load i18n properties files.
13+
* Special case: "sr_Latn" is also supported, although the BCP47 script part is not supported by the Java locale format.
14+
*
15+
* Variants are limited to the format from BCP47, but with underscores instead of hyphens.
16+
*/
17+
// [ language ] [ region ][ variants ]
18+
const rLegacyJavaLocale = /^([a-z]{2,3}|sr_Latn)(?:_([A-Z]{2}|\d{3})((?:_[0-9a-zA-Z]{5,8}|_[0-9][0-9a-zA-Z]{3})*)?)?$/;
19+
20+
// See https://github.com/SAP/openui5/blob/d7ecf2792788719d35b4eee3085a327d545bab24/src/sap.ui.core/src/sap/base/i18n/LanguageFallback.js#L10
21+
const sapSupportabilityVariants = ["saptrc", "sappsd", "saprigi"];
22+
23+
function getBCP47LocaleFromPropertiesFilename(locale) {
24+
const match = rLegacyJavaLocale.exec(locale);
25+
if (!match) {
26+
return null;
27+
}
28+
let [, language, region, variants] = match;
29+
let script;
30+
31+
variants = variants?.slice(1); // Remove leading underscore
32+
33+
// Special handling of sr_Latn (see regex above)
34+
// Note: This needs to be in sync with the runtime logic:
35+
// https://github.com/SAP/openui5/blob/d7ecf2792788719d35b4eee3085a327d545bab24/src/sap.ui.core/src/sap/base/i18n/LanguageFallback.js#L87
36+
if (language === "sr_Latn") {
37+
language = "sr";
38+
script = "Latn";
39+
}
40+
41+
if (language === "en" && region === "US" && sapSupportabilityVariants.includes(variants)) {
42+
// Convert to private use section
43+
// Note: This needs to be in sync with the runtime logic:
44+
// https://github.com/SAP/openui5/blob/d7ecf2792788719d35b4eee3085a327d545bab24/src/sap.ui.core/src/sap/base/i18n/LanguageFallback.js#L75
45+
variants = `x-${variants}`;
46+
}
47+
48+
let bcp47Locale = language;
49+
if (script) {
50+
bcp47Locale += `-${script}`;
51+
}
52+
if (region) {
53+
bcp47Locale += `-${region}`;
54+
}
55+
if (variants) {
56+
// Convert to BCP47 variant format
57+
bcp47Locale += `-${variants.replace(/_/g, "-")}`;
58+
}
59+
return bcp47Locale;
60+
}
61+
1062
function isAbsoluteUrl(url) {
1163
if (url.startsWith("/")) {
1264
return true;
@@ -132,6 +184,8 @@ class ManifestEnhancer {
132184

133185
this.isModified = false;
134186
this.runInvoked = false;
187+
188+
this.supportedLocalesCache = new Map();
135189
}
136190

137191
markModified() {
@@ -151,7 +205,14 @@ class ManifestEnhancer {
151205
}
152206
}
153207

154-
async findSupportedLocales(i18nBundleUrl) {
208+
findSupportedLocales(i18nBundleUrl) {
209+
if (!this.supportedLocalesCache.has(i18nBundleUrl)) {
210+
this.supportedLocalesCache.set(i18nBundleUrl, this._findSupportedLocales(i18nBundleUrl));
211+
}
212+
return this.supportedLocalesCache.get(i18nBundleUrl);
213+
}
214+
215+
async _findSupportedLocales(i18nBundleUrl) {
155216
const i18nBundleName = path.basename(i18nBundleUrl, ".properties");
156217
const i18nBundlePrefix = `${i18nBundleName}_`;
157218
const i18nBundleDir = path.dirname(i18nBundleUrl);
@@ -165,8 +226,13 @@ class ManifestEnhancer {
165226
if (fileNameWithoutExtension === i18nBundleName) {
166227
supportedLocales.push("");
167228
} else if (fileNameWithoutExtension.startsWith(i18nBundlePrefix)) {
168-
const locale = fileNameWithoutExtension.replace(i18nBundlePrefix, "");
169-
supportedLocales.push(locale);
229+
const fileNameLocale = fileNameWithoutExtension.replace(i18nBundlePrefix, "");
230+
const bcp47Locale = getBCP47LocaleFromPropertiesFilename(fileNameLocale);
231+
if (bcp47Locale) {
232+
supportedLocales.push(bcp47Locale);
233+
} else {
234+
log.warn(`Ignoring unexpected locale in filename '${fileName}' for bundle '${i18nBundleUrl}'`);
235+
}
170236
}
171237
});
172238
return supportedLocales.sort();

test/expected/build/application.o/dest/Component-preload.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/expected/build/application.o/dest/i18n/i18n_en_US_sapprc.properties

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
welcome=Hello EN US saptrc world

test/expected/build/application.o/dest/manifest.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
"supportedLocales": [
1313
"",
1414
"en",
15-
"en_US",
16-
"en_US_sapprc"
15+
"en-US",
16+
"en-US-x-saptrc"
1717
]
1818
}
1919
},
@@ -26,8 +26,8 @@
2626
"supportedLocales": [
2727
"",
2828
"en",
29-
"en_US",
30-
"en_US_sapprc"
29+
"en-US",
30+
"en-US-x-saptrc"
3131
]
3232
}
3333
},
@@ -38,8 +38,8 @@
3838
"supportedLocales": [
3939
"",
4040
"en",
41-
"en_US",
42-
"en_US_sapprc"
41+
"en-US",
42+
"en-US-x-saptrc"
4343
]
4444
}
4545
}

test/fixtures/application.o/webapp/i18n/i18n_en_US_sapprc.properties

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
welcome=Hello EN US saptrc world

test/lib/processors/manifestEnhancer.js

Lines changed: 172 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,34 @@ import test from "ava";
22
import sinonGlobal from "sinon";
33
import esmock from "esmock";
44

5+
function isValidBCP47Locale(locale) {
6+
if (locale === "") {
7+
// Special handling of empty string, as this marks the developer locale (without locale information)
8+
return true;
9+
}
10+
11+
// See https://github.com/SAP/openui5/blob/a8f36e430f1fac172eb705811da4a2af25483408/src/sap.ui.core/src/sap/base/i18n/ResourceBundle.js#L30
12+
/**
13+
* A regular expression that describes language tags according to BCP-47.
14+
*
15+
* @see BCP47 "Tags for Identifying Languages" (http://www.ietf.org/rfc/bcp/bcp47.txt)
16+
*
17+
* The matching groups are
18+
* 0=all
19+
* 1=language (shortest ISO639 code + ext. language sub tags | 4digits (reserved) | registered language sub tags)
20+
* 2=script (4 letters)
21+
* 3=region (2letter language or 3 digits)
22+
* 4=variants (separated by '-', Note: capturing group contains leading '-' to shorten the regex!)
23+
* 5=extensions (including leading singleton, multiple extensions separated by '-')
24+
* 6=private use section (including leading 'x', multiple sections separated by '-')
25+
*/
26+
27+
// eslint-disable-next-line max-len
28+
// [-------------------- language ----------------------][--- script ---][------- region --------][------------- variants --------------][----------- extensions ------------][------ private use -------]
29+
const rLocale = /^((?:[A-Z]{2,3}(?:-[A-Z]{3}){0,3})|[A-Z]{4}|[A-Z]{5,8})(?:-([A-Z]{4}))?(?:-([A-Z]{2}|[0-9]{3}))?((?:-[0-9A-Z]{5,8}|-[0-9][0-9A-Z]{3})*)((?:-[0-9A-WYZ](?:-[0-9A-Z]{2,8})+)*)(?:-(X(?:-[0-9A-Z]{1,8})+))?$/i;
30+
return rLocale.test(locale);
31+
}
32+
533
test.beforeEach(async (t) => {
634
const sinon = t.context.sinon = sinonGlobal.createSandbox();
735
t.context.logWarnSpy = sinon.spy();
@@ -3032,20 +3060,143 @@ test("manifestEnhancer#getSupportedLocales", async (t) => {
30323060
fs.readdir.withArgs("/i18n")
30333061
.callsArgWith(1, null, [
30343062
"i18n.properties",
3035-
"i18n_en.properties"
3063+
"i18n_ar_001.properties",
3064+
"i18n_ar_001_variant.properties",
3065+
"i18n_crn.properties",
3066+
"i18n_en.properties",
3067+
"i18n_en_US.properties",
3068+
"i18n_en_US_saptrc.properties",
3069+
"i18n_en_US_saprigi.properties",
3070+
"i18n_sr_Latn_RS.properties",
3071+
"i18n_sr_Latn_RS_variant.properties"
30363072
]);
30373073

3038-
t.deepEqual(await manifestEnhancer.getSupportedLocales("./i18n/i18n.properties"), ["", "en"]);
3039-
t.deepEqual(await manifestEnhancer.getSupportedLocales("i18n/../i18n/i18n.properties"), ["", "en"]);
3040-
t.deepEqual(await manifestEnhancer.getSupportedLocales("ui5://sap/ui/demo/app/i18n/i18n.properties"), ["", "en"]);
3074+
const expectedLocales = [
3075+
"",
3076+
"ar-001",
3077+
"ar-001-variant",
3078+
"crn",
3079+
"en",
3080+
"en-US",
3081+
"en-US-x-saprigi",
3082+
"en-US-x-saptrc",
3083+
"sr-Latn-RS",
3084+
"sr-Latn-RS-variant",
3085+
];
3086+
3087+
const generatedLocales = await manifestEnhancer.getSupportedLocales("./i18n/i18n.properties");
3088+
3089+
t.deepEqual(generatedLocales, expectedLocales);
3090+
t.deepEqual(await manifestEnhancer.getSupportedLocales("i18n/../i18n/i18n.properties"), expectedLocales);
3091+
t.deepEqual(await manifestEnhancer.getSupportedLocales("ui5://sap/ui/demo/app/i18n/i18n.properties"), expectedLocales);
30413092

30423093
// Path traversal to root and then into application namespace
30433094
// This works, but is not recommended at all! It also likely fails at runtime
30443095
t.deepEqual(await manifestEnhancer.getSupportedLocales(
30453096
"../../../../../../../../../../../../resources/sap/ui/demo/app/i18n/i18n.properties"
3046-
), ["", "en"]);
3097+
), expectedLocales);
3098+
3099+
// findSupportedLocales caches requests, so for the same bundle readdir is only called once
3100+
t.is(fs.readdir.callCount, 1);
3101+
3102+
t.true(t.context.logVerboseSpy.notCalled, "No verbose messages should be logged");
3103+
t.true(t.context.logWarnSpy.notCalled, "No warnings should be logged");
3104+
t.true(t.context.logErrorSpy.notCalled, "No errors should be logged");
3105+
3106+
// Check whether generated locales are valid BCP47 locales, as the UI5 runtime
3107+
// fails if a locale is not a valid
3108+
generatedLocales.forEach((locale) => {
3109+
t.true(isValidBCP47Locale(locale), `Generated locale '${locale}' should be a valid BCP47 locale`);
3110+
});
3111+
});
3112+
3113+
test("manifestEnhancer#getSupportedLocales (invalid file names)", async (t) => {
3114+
const {fs} = t.context;
3115+
const {ManifestEnhancer} = t.context.__internals__;
3116+
3117+
const manifest = JSON.stringify({
3118+
"_version": "1.58.0",
3119+
"sap.app": {
3120+
"id": "sap.ui.demo.app"
3121+
}
3122+
});
3123+
const filePath = "/manifest.json";
3124+
3125+
const manifestEnhancer = new ManifestEnhancer(manifest, filePath, fs);
3126+
3127+
const fileNames = [
3128+
"i18n.properties",
3129+
"i18n_en.properties",
3130+
3131+
// Invalid: Should be "en_US"
3132+
"i18n_en-US.properties",
30473133

3048-
t.is(fs.readdir.callCount, 4);
3134+
// Invalid: Should be "zh_CN"
3135+
"i18n_zh_CN_.properties",
3136+
3137+
// Invalid: Script section is only supported for "sr_Latn"
3138+
"i18n_en_Latn_US.properties",
3139+
3140+
// Invalid: Legacy Java locale format does have a BCP47 "extension" section
3141+
"i18n_sr_Latn_RS_variant_f_11.properties",
3142+
3143+
// Invalid: Legacy Java locale format does have a BCP47 "private use" section
3144+
"i18n_sr_Latn_RS_variant_x_private.properties",
3145+
3146+
// Invalid: Legacy Java locale format does have BCP47 "extension" / "private use" sections
3147+
"i18n_sr_Latn_RS_variant_f_11_x_private.properties",
3148+
3149+
// Invalid: Invalid variant length (too short)
3150+
"i18n_de_CH_var.properties",
3151+
3152+
// Invalid: Invalid variant length (too long)
3153+
"i18n_de_CH_variant11.properties",
3154+
3155+
// Invalid: Invalid variant length (too long)
3156+
"i18n_de_CH_001FOOBAR.properties",
3157+
3158+
// Invalid: Should be "en_US_saprigi"
3159+
"i18n_en_US_x_saprigi.properties"
3160+
];
3161+
3162+
fs.readdir.withArgs("/i18n")
3163+
.callsArgWith(1, null, fileNames);
3164+
3165+
const expectedLocales = [
3166+
"",
3167+
"en"
3168+
];
3169+
3170+
t.deepEqual(await manifestEnhancer.getSupportedLocales("./i18n/i18n.properties"), expectedLocales);
3171+
3172+
t.is(fs.readdir.callCount, 1);
3173+
3174+
t.is(t.context.logWarnSpy.callCount, 10);
3175+
t.is(t.context.logWarnSpy.getCall(0).args[0],
3176+
"Ignoring unexpected locale in filename 'i18n_en-US.properties' for bundle 'i18n/i18n.properties'");
3177+
t.is(t.context.logWarnSpy.getCall(1).args[0],
3178+
"Ignoring unexpected locale in filename 'i18n_zh_CN_.properties' for bundle 'i18n/i18n.properties'");
3179+
t.is(t.context.logWarnSpy.getCall(2).args[0],
3180+
"Ignoring unexpected locale in filename 'i18n_en_Latn_US.properties' for bundle 'i18n/i18n.properties'");
3181+
t.is(t.context.logWarnSpy.getCall(3).args[0],
3182+
"Ignoring unexpected locale in filename 'i18n_sr_Latn_RS_variant_f_11.properties' " +
3183+
"for bundle 'i18n/i18n.properties'");
3184+
t.is(t.context.logWarnSpy.getCall(4).args[0],
3185+
"Ignoring unexpected locale in filename 'i18n_sr_Latn_RS_variant_x_private.properties' " +
3186+
"for bundle 'i18n/i18n.properties'");
3187+
t.is(t.context.logWarnSpy.getCall(5).args[0],
3188+
"Ignoring unexpected locale in filename 'i18n_sr_Latn_RS_variant_f_11_x_private.properties' " +
3189+
"for bundle 'i18n/i18n.properties'");
3190+
t.is(t.context.logWarnSpy.getCall(6).args[0],
3191+
"Ignoring unexpected locale in filename 'i18n_de_CH_var.properties' for bundle 'i18n/i18n.properties'");
3192+
t.is(t.context.logWarnSpy.getCall(7).args[0],
3193+
"Ignoring unexpected locale in filename 'i18n_de_CH_variant11.properties' for bundle 'i18n/i18n.properties'");
3194+
t.is(t.context.logWarnSpy.getCall(8).args[0],
3195+
"Ignoring unexpected locale in filename 'i18n_de_CH_001FOOBAR.properties' for bundle 'i18n/i18n.properties'");
3196+
t.is(t.context.logWarnSpy.getCall(9).args[0],
3197+
"Ignoring unexpected locale in filename 'i18n_en_US_x_saprigi.properties' for bundle 'i18n/i18n.properties'");
3198+
t.true(t.context.logVerboseSpy.notCalled, "No verbose messages should be logged");
3199+
t.true(t.context.logErrorSpy.notCalled, "No errors should be logged");
30493200
});
30503201

30513202
test("manifestEnhancer#getSupportedLocales (absolute / invalid URLs)", async (t) => {
@@ -3076,13 +3227,24 @@ test("manifestEnhancer#getSupportedLocales (absolute / invalid URLs)", async (t)
30763227
t.deepEqual(await manifestEnhancer.getSupportedLocales("sftp:i18n.properties"), []);
30773228
t.deepEqual(await manifestEnhancer.getSupportedLocales("file://i18n.properties"), []);
30783229

3230+
t.is(t.context.logVerboseSpy.callCount, 0, "No verbose messages should be logged");
3231+
30793232
// Path traversal to root
30803233
t.deepEqual(await manifestEnhancer.getSupportedLocales("../../../../../../../../../../../../i18n.properties"), []);
30813234

3235+
t.is(t.context.logVerboseSpy.callCount, 1, "One verbose message should be logged from previous call");
3236+
t.is(t.context.logVerboseSpy.getCall(0).args[0],
3237+
"/manifest.json: bundleUrl '../../../../../../../../../../../../i18n.properties' " +
3238+
"points to a bundle outside of the current namespace 'sap.ui.demo.app', enhancement of " +
3239+
"'supportedLocales' is skipped");
3240+
30823241
// Relative ui5-protocol URL
30833242
t.deepEqual(await manifestEnhancer.getSupportedLocales("ui5:i18n.properties"), []);
30843243

30853244
t.is(fs.readdir.callCount, 0, "readdir should not be called for any absolute / invalid URL");
3245+
t.is(t.context.logVerboseSpy.callCount, 1, "No additional verbose messages should be logged");
3246+
t.true(t.context.logWarnSpy.notCalled, "No warnings should be logged");
3247+
t.true(t.context.logErrorSpy.notCalled, "No errors should be logged");
30863248
});
30873249

30883250
test("manifestEnhancer#getSupportedLocales (error handling)", async (t) => {
@@ -3120,6 +3282,10 @@ test("manifestEnhancer#getSupportedLocales (error handling)", async (t) => {
31203282
});
31213283

31223284
t.is(fs.readdir.callCount, 2, "readdir should be called once");
3285+
3286+
t.true(t.context.logVerboseSpy.notCalled, "No verbose messages should be logged");
3287+
t.true(t.context.logWarnSpy.notCalled, "No warnings should be logged");
3288+
t.true(t.context.logErrorSpy.notCalled, "No errors should be logged");
31233289
});
31243290

31253291
test("getRelativeBundleUrlFromName", (t) => {

0 commit comments

Comments
 (0)