[v3,2/2] intl: Add tests for plural expression hardening

Message ID 20260504172206.2470310-2-avinal.xlvii@gmail.com (mailing list archive)
State Committed
Headers
Series [v3,1/2] intl: Import plural expression hardening from GNU gettext |

Checks

Context Check Description
redhat-pt-bot/TryBot-apply_patch success Patch applied to master at the time it was sent
redhat-pt-bot/TryBot-32bit success Build for i686

Commit Message

Avinal Kumar May 4, 2026, 5:22 p.m. UTC
  The first test checks for stack overflow.  It uses a plural expression
nested 5000 levels deep using the !(1-(...)) pattern.  The parser
accepts it (below YYMAXDEPTH=10000), but evaluation exeeds
EVAL_MAXDEPTH=100 and falls back to index 0 instead of crashing with
SIGSEGV.

The second test checks for division by zero in plural expression.  The
expression (n!=1)+1/(n!=1729) triggers 1/0 for n=1729.  msgfmt only
validates 0<= n <= 1000, so the .mo file is accepted.  Evaluation
returns PE_INTDIV and falls back instead of raising SIGFPE.

Adaptations from gettext to glibc:

- gettext's plural-3 embeds the nested expresion as a literal string.
This test uses an AWK script (plural-depth.awk) to generate the same
expression.

- gettext uses LANGUAGE= (empty) with LC_ALL=ll and its own locale
setup.  glibc requires a real locale for setlocale() or else the "C"
locale override in dcigettext.c ignores LANGUAGE entirely.

The tests are derived from GNU gettext's plural-3 (commit 021348871a22)
and plural-4 (commit 429ba6c6b835), adapted to glibc's test framework.

Original author: Bruno Haible <bruno@clisp.org>
Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
---
Changes from v2:
- Use TEST_VERIFY
- Style changes in Makefile
- Typo fixed in commit message

 intl/Makefile           | 32 +++++++++++++----
 intl/plural-depth.awk   | 54 +++++++++++++++++++++++++++++
 intl/tst-plural-eval.c  | 76 +++++++++++++++++++++++++++++++++++++++++
 intl/tst-plural-eval.sh | 67 ++++++++++++++++++++++++++++++++++++
 4 files changed, 223 insertions(+), 6 deletions(-)
 create mode 100644 intl/plural-depth.awk
 create mode 100644 intl/tst-plural-eval.c
 create mode 100644 intl/tst-plural-eval.sh
  

Comments

Adhemerval Zanella Netto May 4, 2026, 5:56 p.m. UTC | #1
On 04/05/26 14:22, Avinal Kumar wrote:
> The first test checks for stack overflow.  It uses a plural expression
> nested 5000 levels deep using the !(1-(...)) pattern.  The parser
> accepts it (below YYMAXDEPTH=10000), but evaluation exeeds
> EVAL_MAXDEPTH=100 and falls back to index 0 instead of crashing with
> SIGSEGV.
> 
> The second test checks for division by zero in plural expression.  The
> expression (n!=1)+1/(n!=1729) triggers 1/0 for n=1729.  msgfmt only
> validates 0<= n <= 1000, so the .mo file is accepted.  Evaluation
> returns PE_INTDIV and falls back instead of raising SIGFPE.
> 
> Adaptations from gettext to glibc:
> 
> - gettext's plural-3 embeds the nested expresion as a literal string.
> This test uses an AWK script (plural-depth.awk) to generate the same
> expression.
> 
> - gettext uses LANGUAGE= (empty) with LC_ALL=ll and its own locale
> setup.  glibc requires a real locale for setlocale() or else the "C"
> locale override in dcigettext.c ignores LANGUAGE entirely.
> 
> The tests are derived from GNU gettext's plural-3 (commit 021348871a22)
> and plural-4 (commit 429ba6c6b835), adapted to glibc's test framework.
> 
> Original author: Bruno Haible <bruno@clisp.org>
> Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>

LGTM, thanks.

Reviewed-by: Adhemerval Zanella <adhemerval.zanella@linaro.org>

> ---
> Changes from v2:
> - Use TEST_VERIFY
> - Style changes in Makefile
> - Typo fixed in commit message
> 
>  intl/Makefile           | 32 +++++++++++++----
>  intl/plural-depth.awk   | 54 +++++++++++++++++++++++++++++
>  intl/tst-plural-eval.c  | 76 +++++++++++++++++++++++++++++++++++++++++
>  intl/tst-plural-eval.sh | 67 ++++++++++++++++++++++++++++++++++++
>  4 files changed, 223 insertions(+), 6 deletions(-)
>  create mode 100644 intl/plural-depth.awk
>  create mode 100644 intl/tst-plural-eval.c
>  create mode 100644 intl/tst-plural-eval.sh
> 
> diff --git a/intl/Makefile b/intl/Makefile
> index 42875eb1a9..a8b41a1993 100644
> --- a/intl/Makefile
> +++ b/intl/Makefile
> @@ -22,13 +22,21 @@ subdir = intl
>  include ../Makeconfig
>  
>  headers = libintl.h
> -routines = bindtextdom dcgettext dgettext gettext	\
> +routines = bindtextdom dcgettext dgettext gettext \
>  	   dcigettext dcngettext dngettext ngettext \
>  	   finddomain loadmsgcat localealias textdomain
> -aux =	   l10nflist explodename plural plural-exp hash-string
> +aux = l10nflist explodename plural plural-exp hash-string
>  
>  multithread-test-srcs := tst-gettext4 tst-gettext5 tst-gettext6
> -test-srcs := tst-gettext tst-translit tst-gettext2 tst-codeset tst-gettext3
> +test-srcs := \
> +	tst-gettext \
> +	tst-translit \
> +	tst-gettext2 \
> +	tst-codeset \
> +	tst-gettext3 \
> +	tst-plural-eval
> +	# test-srcs
> +
>  ifeq ($(have-thread-library),yes)
>  test-srcs += $(multithread-test-srcs)
>  endif
> @@ -53,9 +61,15 @@ $(objpfx)plural.o: $(objpfx)plural.c
>  ifeq ($(run-built-tests),yes)
>  ifeq (yes,$(build-shared))
>  ifneq ($(strip $(MSGFMT)),:)
> -tests-special += $(objpfx)tst-translit.out $(objpfx)tst-gettext.out \
> -		 $(objpfx)tst-gettext2.out $(objpfx)tst-codeset.out \
> -		 $(objpfx)tst-gettext3.out
> +tests-special += \
> +	$(objpfx)tst-translit.out \
> +	$(objpfx)tst-gettext.out \
> +	$(objpfx)tst-gettext2.out \
> +	$(objpfx)tst-codeset.out \
> +	$(objpfx)tst-gettext3.out \
> +	$(objpfx)tst-plural-eval.out
> +	# tests-special
> +
>  ifeq ($(have-thread-library),yes)
>  tests-special += $(objpfx)tst-gettext4.out $(objpfx)tst-gettext5.out \
>  		 $(objpfx)tst-gettext6.out
> @@ -103,6 +117,10 @@ $(objpfx)tst-gettext4.out: tst-gettext4.sh $(objpfx)tst-gettext4
>  $(objpfx)tst-gettext6.out: tst-gettext6.sh $(objpfx)tst-gettext6
>  	$(SHELL) $< $(common-objpfx) '$(test-program-prefix)' $(common-objpfx)intl/; \
>  	$(evaluate-test)
> +$(objpfx)tst-plural-eval.out: tst-plural-eval.sh $(objpfx)tst-plural-eval
> +	$(SHELL) $< $(common-objpfx) '$(test-program-prefix)' \
> +	  $(common-objpfx)intl/; \
> +	$(evaluate-test)
>  
>  $(objpfx)tst-codeset.out: $(codeset_mo)
>  $(objpfx)tst-gettext3.out: $(codeset_mo)
> @@ -140,6 +158,7 @@ CFLAGS-tst-gettext3.c += -DOBJPFX=\"$(objpfx)\"
>  CFLAGS-tst-gettext4.c += -DOBJPFX=\"$(objpfx)\"
>  CFLAGS-tst-gettext5.c += -DOBJPFX=\"$(objpfx)\"
>  CFLAGS-tst-gettext6.c += -DOBJPFX=\"$(objpfx)\"
> +CFLAGS-tst-plural-eval.c += -DOBJPFX=\"$(objpfx)\"
>  
>  ifeq ($(have-thread-library),yes)
>  ifeq (yes,$(build-shared))
> @@ -156,6 +175,7 @@ $(objpfx)tst-gettext3.out: $(objpfx)tst-gettext.out
>  $(objpfx)tst-gettext4.out: $(objpfx)tst-gettext.out
>  $(objpfx)tst-gettext5.out: $(objpfx)tst-gettext.out
>  $(objpfx)tst-gettext6.out: $(objpfx)tst-gettext.out
> +$(objpfx)tst-plural-eval.out: $(objpfx)tst-gettext.out
>  
>  CPPFLAGS += -D'LOCALEDIR="$(localedir)"' \
>  	    -D'LOCALE_ALIAS_PATH="$(localedir)"' \
> diff --git a/intl/plural-depth.awk b/intl/plural-depth.awk
> new file mode 100644
> index 0000000000..7b200c00e9
> --- /dev/null
> +++ b/intl/plural-depth.awk
> @@ -0,0 +1,54 @@
> +# plural-depth.awk - Generate .po file with deeply nested plural expression.
> +# Copyright (C) 2026 Free Software Foundation, Inc.
> +#
> +# This file is part of the GNU C Library.
> +#
> +# The GNU C Library is free software; you can redistribute it and/or
> +# modify it under the terms of the GNU Lesser General Public
> +# License as published by the Free Software Foundation; either
> +# version 2.1 of the License, or (at your option) any later version.
> +#
> +# The GNU C Library is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
> +# Lesser General Public License for more details.
> +#
> +# You should have received a copy of the GNU Lesser General Public
> +# License along with the GNU C Library; if not, see
> +# <https://www.gnu.org/licenses/>.
> +
> +# Generate a .po file whose Plural-Forms header contains a plural
> +# expression nested DEPTH levels deep.  Each level wraps as !(1-(...)),
> +# producing an expression that is accepted by the parser (YYMAXDEPTH=10000)
> +# but exceeds EVAL_MAXDEPTH=100 at runtime.
> +#
> +# Usage: awk -v DEPTH=5000 -f plural-depth.awk > plural-depth.po
> +
> +BEGIN {
> +    if (DEPTH == 0)
> +	DEPTH = 5000
> +
> +    expr = ""
> +    for (i = 0; i < DEPTH; i++)
> +	expr = expr "!(1-"
> +    expr = expr "(n!=1)"
> +    for (i = 0; i < DEPTH; i++)
> +	expr = expr ")"
> +
> +    print "msgid \"\""
> +    print "msgstr \"\""
> +    print "\"Project-Id-Version: test\\n\""
> +    print "\"PO-Revision-Date: 2026-01-01 00:00+0000\\n\""
> +    print "\"Last-Translator: \\n\""
> +    print "\"Language-Team: \\n\""
> +    print "\"Language: ll\\n\""
> +    print "\"MIME-Version: 1.0\\n\""
> +    print "\"Content-Type: text/plain; charset=ASCII\\n\""
> +    print "\"Content-Transfer-Encoding: 8bit\\n\""
> +    print "\"Plural-Forms: nplurals=2; plural=" expr ";\\n\""
> +    print ""
> +    print "msgid \"X\""
> +    print "msgid_plural \"Y\""
> +    print "msgstr[0] \"x\""
> +    print "msgstr[1] \"y\""
> +}
> diff --git a/intl/tst-plural-eval.c b/intl/tst-plural-eval.c
> new file mode 100644
> index 0000000000..8658598330
> --- /dev/null
> +++ b/intl/tst-plural-eval.c
> @@ -0,0 +1,76 @@
> +/* Test plural expression evaluation hardening.
> +   Copyright (C) 2026 Free Software Foundation, Inc.
> +   This file is part of the GNU C Library.
> +
> +   The GNU C Library is free software; you can redistribute it and/or
> +   modify it under the terms of the GNU Lesser General Public
> +   License as published by the Free Software Foundation; either
> +   version 2.1 of the License, or (at your option) any later version.
> +
> +   The GNU C Library is distributed in the hope that it will be useful,
> +   but WITHOUT ANY WARRANTY; without even the implied warranty of
> +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
> +   Lesser General Public License for more details.
> +
> +   You should have received a copy of the GNU Lesser General Public
> +   License along with the GNU C Library; if not, see
> +   <https://www.gnu.org/licenses/>.  */
> +
> +#include <libintl.h>
> +#include <locale.h>
> +#include <stdio.h>
> +#include <stdlib.h>
> +#include <string.h>
> +#include <support/check.h>
> +#include <support/support.h>
> +
> +static int
> +do_test (void)
> +{
> +  unsetenv ("OUTPUT_CHARSET");
> +
> +  /* Use a real locale so that the active locale is not "C".  */
> +  TEST_VERIFY (setenv ("LC_ALL", "de_DE.UTF-8", 1) == 0);
> +  xsetlocale (LC_ALL, "");
> +  /* Set a dummy langauge to override lookup.  */
> +  TEST_VERIFY (setenv ("LANGUAGE", "ll", 1) == 0);
> +
> +  /* Test 1: Stack overflow in plural evaluation.
> +     The .mo file has a plural expression with 5000 levels of nesting
> +     like !(1-(!(1-(...(n!=1)...)))).  Before the fix, plural_eval()
> +     used unbounded recursion and would crash with SIGSEGV on threads
> +     with small stacks.  After the fix, EVAL_MAXDEPTH=100 causes
> +     plural_eval_recurse() to return PE_STACKOVF, and plural_lookup()
> +     falls back to index 0 (the singular form).  */
> +
> +  TEST_VERIFY (bindtextdomain ("plural-depth", OBJPFX "domaindir") != NULL);
> +  TEST_VERIFY (textdomain ("plural-depth") != NULL);
> +
> +  /* ngettext must not crash.  The return value depends on whether
> +     the depth limit is hit (falls back to index 0) or the expression
> +     evaluates successfully.  Either result is acceptable.  */
> +
> +  const char *tr = ngettext ("X", "Y", 42);
> +  TEST_VERIFY (tr != NULL);
> +  TEST_VERIFY (strcmp (tr, "x") == 0 || strcmp (tr, "y") == 0);
> +
> +  /* Test 2: Division by zero in plural evaluation.
> +     The .mo file has plural expression (n!=1)+1/(n!=1729).
> +     For n=1729, (n!=1729) is 0, so 1/0 triggers division by zero.
> +     Before the fix, this raised SIGFPE.  After the fix,
> +     plural_eval_recurse() returns PE_INTDIV, and plural_lookup()
> +     falls back to index 0.  */
> +
> +  TEST_VERIFY (bindtextdomain ("plural-divzero", OBJPFX "domaindir") != NULL);
> +  TEST_VERIFY (textdomain ("plural-divzero") != NULL);
> +
> +  /* ngettext with n=1729 must not crash with SIGFPE.  */
> +  tr = ngettext ("X", "Y", 1729);
> +  TEST_VERIFY (tr != NULL);
> +  TEST_VERIFY (strcmp (tr, "x") == 0 || strcmp (tr, "y") == 0
> +	       || strcmp (tr, "z") == 0);
> +
> +  return 0;
> +}
> +
> +#include <support/test-driver.c>
> diff --git a/intl/tst-plural-eval.sh b/intl/tst-plural-eval.sh
> new file mode 100644
> index 0000000000..f020fa9925
> --- /dev/null
> +++ b/intl/tst-plural-eval.sh
> @@ -0,0 +1,67 @@
> +#!/bin/sh
> +# Test plural expression evaluation hardening.
> +# Copyright (C) 2026 Free Software Foundation, Inc.
> +# This file is part of the GNU C Library.
> +
> +# The GNU C Library is free software; you can redistribute it and/or
> +# modify it under the terms of the GNU Lesser General Public
> +# License as published by the Free Software Foundation; either
> +# version 2.1 of the License, or (at your option) any later version.
> +
> +# The GNU C Library is distributed in the hope that it will be useful,
> +# but WITHOUT ANY WARRANTY; without even the implied warranty of
> +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
> +# Lesser General Public License for more details.
> +
> +# You should have received a copy of the GNU Lesser General Public
> +# License along with the GNU C Library; if not, see
> +# <https://www.gnu.org/licenses/>.
> +
> +set -e
> +
> +common_objpfx=$1
> +test_program_prefix=$2
> +objpfx=$3
> +
> +# Create domain directories.
> +mkdir -p ${objpfx}domaindir/ll/LC_MESSAGES
> +
> +# Test 1: Deeply nested plural expression (stack overflow test).
> +# This expression has 5000 levels of nesting, well above EVAL_MAXDEPTH=100
> +# but below YYMAXDEPTH=10000 so the parser accepts it.
> +LC_ALL=C awk -v DEPTH=5000 -f plural-depth.awk > ${objpfx}plural-depth.po
> +
> +msgfmt -o ${objpfx}domaindir/ll/LC_MESSAGES/plural-depth.mo \
> +       ${objpfx}plural-depth.po || exit 1
> +
> +# Test 2: Division by zero in plural expression (SIGFPE test).
> +# The expression 1/(n!=1729) triggers division by zero for n=1729.
> +# msgfmt -c only checks 0 <= n <= 1000, so this passes validation.
> +cat > ${objpfx}plural-divzero.po <<EOF
> +msgid ""
> +msgstr ""
> +"Project-Id-Version: test\n"
> +"PO-Revision-Date: 2026-01-01 00:00+0000\n"
> +"Last-Translator: \n"
> +"Language-Team: \n"
> +"Language: ll\n"
> +"MIME-Version: 1.0\n"
> +"Content-Type: text/plain; charset=ASCII\n"
> +"Content-Transfer-Encoding: 8bit\n"
> +"Plural-Forms: nplurals=3; plural=(n!=1)+1/(n!=1729);\n"
> +
> +msgid "X"
> +msgid_plural "Y"
> +msgstr[0] "x"
> +msgstr[1] "y"
> +msgstr[2] "z"
> +EOF
> +
> +msgfmt -o ${objpfx}domaindir/ll/LC_MESSAGES/plural-divzero.mo \
> +       ${objpfx}plural-divzero.po || exit 1
> +
> +# Run the test.
> +${test_program_prefix} \
> +${objpfx}tst-plural-eval ${objpfx}domaindir
> +
> +exit $?
  

Patch

diff --git a/intl/Makefile b/intl/Makefile
index 42875eb1a9..a8b41a1993 100644
--- a/intl/Makefile
+++ b/intl/Makefile
@@ -22,13 +22,21 @@  subdir = intl
 include ../Makeconfig
 
 headers = libintl.h
-routines = bindtextdom dcgettext dgettext gettext	\
+routines = bindtextdom dcgettext dgettext gettext \
 	   dcigettext dcngettext dngettext ngettext \
 	   finddomain loadmsgcat localealias textdomain
-aux =	   l10nflist explodename plural plural-exp hash-string
+aux = l10nflist explodename plural plural-exp hash-string
 
 multithread-test-srcs := tst-gettext4 tst-gettext5 tst-gettext6
-test-srcs := tst-gettext tst-translit tst-gettext2 tst-codeset tst-gettext3
+test-srcs := \
+	tst-gettext \
+	tst-translit \
+	tst-gettext2 \
+	tst-codeset \
+	tst-gettext3 \
+	tst-plural-eval
+	# test-srcs
+
 ifeq ($(have-thread-library),yes)
 test-srcs += $(multithread-test-srcs)
 endif
@@ -53,9 +61,15 @@  $(objpfx)plural.o: $(objpfx)plural.c
 ifeq ($(run-built-tests),yes)
 ifeq (yes,$(build-shared))
 ifneq ($(strip $(MSGFMT)),:)
-tests-special += $(objpfx)tst-translit.out $(objpfx)tst-gettext.out \
-		 $(objpfx)tst-gettext2.out $(objpfx)tst-codeset.out \
-		 $(objpfx)tst-gettext3.out
+tests-special += \
+	$(objpfx)tst-translit.out \
+	$(objpfx)tst-gettext.out \
+	$(objpfx)tst-gettext2.out \
+	$(objpfx)tst-codeset.out \
+	$(objpfx)tst-gettext3.out \
+	$(objpfx)tst-plural-eval.out
+	# tests-special
+
 ifeq ($(have-thread-library),yes)
 tests-special += $(objpfx)tst-gettext4.out $(objpfx)tst-gettext5.out \
 		 $(objpfx)tst-gettext6.out
@@ -103,6 +117,10 @@  $(objpfx)tst-gettext4.out: tst-gettext4.sh $(objpfx)tst-gettext4
 $(objpfx)tst-gettext6.out: tst-gettext6.sh $(objpfx)tst-gettext6
 	$(SHELL) $< $(common-objpfx) '$(test-program-prefix)' $(common-objpfx)intl/; \
 	$(evaluate-test)
+$(objpfx)tst-plural-eval.out: tst-plural-eval.sh $(objpfx)tst-plural-eval
+	$(SHELL) $< $(common-objpfx) '$(test-program-prefix)' \
+	  $(common-objpfx)intl/; \
+	$(evaluate-test)
 
 $(objpfx)tst-codeset.out: $(codeset_mo)
 $(objpfx)tst-gettext3.out: $(codeset_mo)
@@ -140,6 +158,7 @@  CFLAGS-tst-gettext3.c += -DOBJPFX=\"$(objpfx)\"
 CFLAGS-tst-gettext4.c += -DOBJPFX=\"$(objpfx)\"
 CFLAGS-tst-gettext5.c += -DOBJPFX=\"$(objpfx)\"
 CFLAGS-tst-gettext6.c += -DOBJPFX=\"$(objpfx)\"
+CFLAGS-tst-plural-eval.c += -DOBJPFX=\"$(objpfx)\"
 
 ifeq ($(have-thread-library),yes)
 ifeq (yes,$(build-shared))
@@ -156,6 +175,7 @@  $(objpfx)tst-gettext3.out: $(objpfx)tst-gettext.out
 $(objpfx)tst-gettext4.out: $(objpfx)tst-gettext.out
 $(objpfx)tst-gettext5.out: $(objpfx)tst-gettext.out
 $(objpfx)tst-gettext6.out: $(objpfx)tst-gettext.out
+$(objpfx)tst-plural-eval.out: $(objpfx)tst-gettext.out
 
 CPPFLAGS += -D'LOCALEDIR="$(localedir)"' \
 	    -D'LOCALE_ALIAS_PATH="$(localedir)"' \
diff --git a/intl/plural-depth.awk b/intl/plural-depth.awk
new file mode 100644
index 0000000000..7b200c00e9
--- /dev/null
+++ b/intl/plural-depth.awk
@@ -0,0 +1,54 @@ 
+# plural-depth.awk - Generate .po file with deeply nested plural expression.
+# Copyright (C) 2026 Free Software Foundation, Inc.
+#
+# This file is part of the GNU C Library.
+#
+# The GNU C Library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# The GNU C Library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with the GNU C Library; if not, see
+# <https://www.gnu.org/licenses/>.
+
+# Generate a .po file whose Plural-Forms header contains a plural
+# expression nested DEPTH levels deep.  Each level wraps as !(1-(...)),
+# producing an expression that is accepted by the parser (YYMAXDEPTH=10000)
+# but exceeds EVAL_MAXDEPTH=100 at runtime.
+#
+# Usage: awk -v DEPTH=5000 -f plural-depth.awk > plural-depth.po
+
+BEGIN {
+    if (DEPTH == 0)
+	DEPTH = 5000
+
+    expr = ""
+    for (i = 0; i < DEPTH; i++)
+	expr = expr "!(1-"
+    expr = expr "(n!=1)"
+    for (i = 0; i < DEPTH; i++)
+	expr = expr ")"
+
+    print "msgid \"\""
+    print "msgstr \"\""
+    print "\"Project-Id-Version: test\\n\""
+    print "\"PO-Revision-Date: 2026-01-01 00:00+0000\\n\""
+    print "\"Last-Translator: \\n\""
+    print "\"Language-Team: \\n\""
+    print "\"Language: ll\\n\""
+    print "\"MIME-Version: 1.0\\n\""
+    print "\"Content-Type: text/plain; charset=ASCII\\n\""
+    print "\"Content-Transfer-Encoding: 8bit\\n\""
+    print "\"Plural-Forms: nplurals=2; plural=" expr ";\\n\""
+    print ""
+    print "msgid \"X\""
+    print "msgid_plural \"Y\""
+    print "msgstr[0] \"x\""
+    print "msgstr[1] \"y\""
+}
diff --git a/intl/tst-plural-eval.c b/intl/tst-plural-eval.c
new file mode 100644
index 0000000000..8658598330
--- /dev/null
+++ b/intl/tst-plural-eval.c
@@ -0,0 +1,76 @@ 
+/* Test plural expression evaluation hardening.
+   Copyright (C) 2026 Free Software Foundation, Inc.
+   This file is part of the GNU C Library.
+
+   The GNU C Library is free software; you can redistribute it and/or
+   modify it under the terms of the GNU Lesser General Public
+   License as published by the Free Software Foundation; either
+   version 2.1 of the License, or (at your option) any later version.
+
+   The GNU C Library is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+   Lesser General Public License for more details.
+
+   You should have received a copy of the GNU Lesser General Public
+   License along with the GNU C Library; if not, see
+   <https://www.gnu.org/licenses/>.  */
+
+#include <libintl.h>
+#include <locale.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <support/check.h>
+#include <support/support.h>
+
+static int
+do_test (void)
+{
+  unsetenv ("OUTPUT_CHARSET");
+
+  /* Use a real locale so that the active locale is not "C".  */
+  TEST_VERIFY (setenv ("LC_ALL", "de_DE.UTF-8", 1) == 0);
+  xsetlocale (LC_ALL, "");
+  /* Set a dummy langauge to override lookup.  */
+  TEST_VERIFY (setenv ("LANGUAGE", "ll", 1) == 0);
+
+  /* Test 1: Stack overflow in plural evaluation.
+     The .mo file has a plural expression with 5000 levels of nesting
+     like !(1-(!(1-(...(n!=1)...)))).  Before the fix, plural_eval()
+     used unbounded recursion and would crash with SIGSEGV on threads
+     with small stacks.  After the fix, EVAL_MAXDEPTH=100 causes
+     plural_eval_recurse() to return PE_STACKOVF, and plural_lookup()
+     falls back to index 0 (the singular form).  */
+
+  TEST_VERIFY (bindtextdomain ("plural-depth", OBJPFX "domaindir") != NULL);
+  TEST_VERIFY (textdomain ("plural-depth") != NULL);
+
+  /* ngettext must not crash.  The return value depends on whether
+     the depth limit is hit (falls back to index 0) or the expression
+     evaluates successfully.  Either result is acceptable.  */
+
+  const char *tr = ngettext ("X", "Y", 42);
+  TEST_VERIFY (tr != NULL);
+  TEST_VERIFY (strcmp (tr, "x") == 0 || strcmp (tr, "y") == 0);
+
+  /* Test 2: Division by zero in plural evaluation.
+     The .mo file has plural expression (n!=1)+1/(n!=1729).
+     For n=1729, (n!=1729) is 0, so 1/0 triggers division by zero.
+     Before the fix, this raised SIGFPE.  After the fix,
+     plural_eval_recurse() returns PE_INTDIV, and plural_lookup()
+     falls back to index 0.  */
+
+  TEST_VERIFY (bindtextdomain ("plural-divzero", OBJPFX "domaindir") != NULL);
+  TEST_VERIFY (textdomain ("plural-divzero") != NULL);
+
+  /* ngettext with n=1729 must not crash with SIGFPE.  */
+  tr = ngettext ("X", "Y", 1729);
+  TEST_VERIFY (tr != NULL);
+  TEST_VERIFY (strcmp (tr, "x") == 0 || strcmp (tr, "y") == 0
+	       || strcmp (tr, "z") == 0);
+
+  return 0;
+}
+
+#include <support/test-driver.c>
diff --git a/intl/tst-plural-eval.sh b/intl/tst-plural-eval.sh
new file mode 100644
index 0000000000..f020fa9925
--- /dev/null
+++ b/intl/tst-plural-eval.sh
@@ -0,0 +1,67 @@ 
+#!/bin/sh
+# Test plural expression evaluation hardening.
+# Copyright (C) 2026 Free Software Foundation, Inc.
+# This file is part of the GNU C Library.
+
+# The GNU C Library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+
+# The GNU C Library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+
+# You should have received a copy of the GNU Lesser General Public
+# License along with the GNU C Library; if not, see
+# <https://www.gnu.org/licenses/>.
+
+set -e
+
+common_objpfx=$1
+test_program_prefix=$2
+objpfx=$3
+
+# Create domain directories.
+mkdir -p ${objpfx}domaindir/ll/LC_MESSAGES
+
+# Test 1: Deeply nested plural expression (stack overflow test).
+# This expression has 5000 levels of nesting, well above EVAL_MAXDEPTH=100
+# but below YYMAXDEPTH=10000 so the parser accepts it.
+LC_ALL=C awk -v DEPTH=5000 -f plural-depth.awk > ${objpfx}plural-depth.po
+
+msgfmt -o ${objpfx}domaindir/ll/LC_MESSAGES/plural-depth.mo \
+       ${objpfx}plural-depth.po || exit 1
+
+# Test 2: Division by zero in plural expression (SIGFPE test).
+# The expression 1/(n!=1729) triggers division by zero for n=1729.
+# msgfmt -c only checks 0 <= n <= 1000, so this passes validation.
+cat > ${objpfx}plural-divzero.po <<EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: test\n"
+"PO-Revision-Date: 2026-01-01 00:00+0000\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: ll\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=ASCII\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=3; plural=(n!=1)+1/(n!=1729);\n"
+
+msgid "X"
+msgid_plural "Y"
+msgstr[0] "x"
+msgstr[1] "y"
+msgstr[2] "z"
+EOF
+
+msgfmt -o ${objpfx}domaindir/ll/LC_MESSAGES/plural-divzero.mo \
+       ${objpfx}plural-divzero.po || exit 1
+
+# Run the test.
+${test_program_prefix} \
+${objpfx}tst-plural-eval ${objpfx}domaindir
+
+exit $?