diff --git a/fluent.runtime/fluent/runtime/resolver.py b/fluent.runtime/fluent/runtime/resolver.py index b42b14c3..6a16fc01 100644 --- a/fluent.runtime/fluent/runtime/resolver.py +++ b/fluent.runtime/fluent/runtime/resolver.py @@ -161,7 +161,17 @@ def __call__(self, env: ResolverEnvironment) -> Union[str, FluentNone]: raise ValueError( "Too many parts in message (> {}), " "aborting.".format(self.MAX_PARTS) ) - retval = "".join(resolve(element(env), env) for element in elements) + # Text written directly in the pattern is not subject to the + # MAX_PART_LENGTH limit, which protects against placeable expansion. + # Placeables have already resolved (and checked) their own value. + retval = "".join( + ( + element(env) + if isinstance(element, (Literal, Placeable)) + else resolve(element(env), env) + ) + for element in elements + ) env.part_count += len(elements) env.active_patterns.remove(self) return retval @@ -174,11 +184,20 @@ def resolve(fluentish: Any, env: ResolverEnvironment) -> Any: if len(fluentish) > MAX_PART_LENGTH: raise ValueError( "Too many characters in placeable " - "({}, max allowed is {})".format(len(fluentish), Pattern.MAX_PARTS) + "({}, max allowed is {})".format(len(fluentish), MAX_PART_LENGTH) ) return fluentish +def resolve_expression(expression: BaseResolver, env: ResolverEnvironment) -> Any: + value = expression(env) + if isinstance(expression, SelectExpression) and isinstance(value, str): + # The selected variant is a pattern of the same message, which has + # already been resolved with its placeables subject to MAX_PART_LENGTH. + return value + return resolve(value, env) + + class TextElement(FTL.TextElement, Literal): value: str @@ -190,7 +209,7 @@ class Placeable(FTL.Placeable, BaseResolver): expression: Union["InlineExpression", "Placeable", "SelectExpression"] def __call__(self, env: ResolverEnvironment) -> Any: - inner = resolve(self.expression(env), env) + inner = resolve_expression(self.expression, env) if not env.context.use_isolating: return inner return "\u2068" + inner + "\u2069" @@ -200,7 +219,7 @@ class NeverIsolatingPlaceable(FTL.Placeable, BaseResolver): expression: Union["InlineExpression", Placeable, "SelectExpression"] def __call__(self, env: ResolverEnvironment) -> Any: - inner = resolve(self.expression(env), env) + inner = resolve_expression(self.expression, env) return inner diff --git a/fluent.runtime/tests/test_bomb.py b/fluent.runtime/tests/test_bomb.py index 687e9141..b1046e6d 100644 --- a/fluent.runtime/tests/test_bomb.py +++ b/fluent.runtime/tests/test_bomb.py @@ -34,6 +34,7 @@ def test_max_length_protection(self, bundle): assert val == "{???}" assert len(errs) != 0 assert "Too many characters" in str(errs[-1]) + assert "max allowed is 2500" in str(errs[-1]) def test_max_expansions_protection(self, bundle): # Without protection, emptylolz will take a really long time to @@ -42,3 +43,55 @@ def test_max_expansions_protection(self, bundle): assert val == "{???}" assert len(errs) == 1 assert "Too many parts" in str(errs[-1]) + + +class TestLongText: + # The length limit protects against expansion of placeables, and + # should not apply to text written directly in the message. + @pytest.mark.parametrize("use_isolating", [True, False]) + def test_long_text_with_placeable(self, use_isolating): + long_text = "x" * 3000 + bundle = FluentBundle(["en-US"], use_isolating=use_isolating) + bundle.add_resource(FluentResource(f"long = {long_text} {{ $arg }}\n")) + val, errs = bundle.format_pattern( + bundle.get_message("long").value, {"arg": "y"} + ) + y = "\u2068y\u2069" if use_isolating else "y" + assert val == f"{long_text} {y}" + assert errs == [] + + def test_long_string_literal(self): + long_text = "x" * 3000 + bundle = FluentBundle(["en-US"], use_isolating=False) + bundle.add_resource(FluentResource(f'long = {{ "{long_text}" }} {{ $arg }}\n')) + val, errs = bundle.format_pattern( + bundle.get_message("long").value, {"arg": "y"} + ) + assert val == f"{long_text} y" + assert errs == [] + + @pytest.mark.parametrize("n, expected", [(1, "{text}"), (2, "{text} 2")]) + def test_long_text_in_select_variant(self, n, expected): + long_text = "x" * 3000 + bundle = FluentBundle(["en-US"], use_isolating=False) + bundle.add_resource( + FluentResource( + f"long = {{ $n ->\n" + f" [one] {long_text}\n" + f" *[other] {long_text} {{ $n }}\n" + f"}}\n" + ) + ) + val, errs = bundle.format_pattern(bundle.get_message("long").value, {"n": n}) + assert val == expected.format(text=long_text) + assert errs == [] + + def test_long_select_variant_after_text(self): + long_text = "x" * 3000 + bundle = FluentBundle(["en-US"], use_isolating=False) + bundle.add_resource( + FluentResource(f"long = Hi {{ $n ->\n *[other] {long_text}\n}}\n") + ) + val, errs = bundle.format_pattern(bundle.get_message("long").value, {"n": 1}) + assert val == f"Hi {long_text}" + assert errs == []