Skip to content

Keep StrSubstitutor from treating a value's trailing $ as an escape - #1834

Merged
garydgregory merged 1 commit into
apache:masterfrom
alhudz:strsubstitutor-value-escape
Oct 24, 2026
Merged

garydgregory merged 1 commit into
apache:masterfrom
alhudz:strsubstitutor-value-escape

Conversation

@alhudz

@alhudz alhudz commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

StrSubstitutor takes the escape character from the end of a substituted value when another variable follows it directly in the template:

Map<String, String> map = new HashMap<>();
map.put("price", "5$");
map.put("x", "X");
new StrSubstitutor(map).replace("${price}${x}"); // "5${x}", expected "5$X"

After ${price} is replaced, substituteRecursive resumes scanning at the end of the inserted value, and the escape check (pos > offset && chars[pos - 1] == escape) reaches back into that value. The value loses its trailing $ and ${x} is passed through unresolved, so whoever supplies a value can stop the next template variable from being substituted. The class Javadoc places the escape in the template, in front of a variable reference.

The fix only accepts an escape character at or after the end of the last substitution. Escapes written in the template, escapes inside a value, preserveEscapes and nested/recursive substitution behave as before. The new testReplaceValueEndingWithEscape fails without the change (expected: <The price is 5$lazy dog.> but was: <The price is 5${target}.>) and passes with it.

Commons Text 1.15.0 StringSubstitutor behaves the same way ("5${x}" for the input above), so the same change applies there.

  • Read the contribution guidelines for this project.
  • Read the ASF Generative Tooling Guidance if you use Artificial Intelligence (AI).
  • I used AI to create any part of, or all of, this pull request. Which AI tool was used to create this pull request, and to what extent did it contribute? Claude (Anthropic) found the issue and prepared the patch, test and this description; I reviewed it before submitting.
  • Run a successful build using the default Maven goal with mvn; that's mvn on the command line by itself.
  • Write unit tests that match behavioral changes, where the tests fail if the changes to the runtime are not applied. This may not always be possible, but it is a best practice.
  • Write a pull request description that is detailed enough to understand what the pull request does, how, and why.
  • Each commit in the pull request should have a meaningful subject line and body. Note that a maintainer may squash commits during the merge process.

After a variable is replaced, substituteRecursive resumes scanning at
the end of the inserted value, and the escape check looks at the char
before the next prefix bounded only by the scan offset. A value ending
in the escape char therefore escaped the variable that follows it in
the template: the value lost its last char and the next variable was
left unresolved, e.g. price=5$ makes "${price}${x}" return "5${x}"
instead of "5$X".

Only accept an escape char at or after the end of the last
substitution.
@garydgregory garydgregory changed the title keep StrSubstitutor from treating a value's trailing $ as an escape Keep StrSubstitutor from treating a value's trailing $ as an escape Oct 24, 2026
@garydgregory
garydgregory merged commit cb3a20e into apache:master Oct 24, 2026
22 of 23 checks passed
garydgregory added a commit that referenced this pull request Oct 24, 2026
(#1834)

Expand StrSubstitutor escape boundary regression coverage.

Follow up to PR #1834 with tests for preserved escapes, custom
delimiters,
default and empty values, recursive substitutions, and nested variable
names. Clarify escape behavior in the class Javadoc.
@garydgregory

Copy link
Copy Markdown
Member

@alhudz
PR merged but your are missing some regression tests, which I added in commit 390fe40. Please check if Commons Text has the same issue. Thank you!

garydgregory added a commit that referenced this pull request Oct 24, 2026
@alhudz

alhudz commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Yes, Commons Text has the same issue. StringSubstitutor.substitute uses the same check (pos > offset && builder.charAt(pos - 1) == escapeCh), both in 1.15.0 and on current master, so a value's trailing escape char gets read as a template escape there as well.

Repro against 1.15.0:

Map<String, String> m = Map.of("price", "5$", "x", "X", "a", "$", "b", "B");
StringSubstitutor s = new StringSubstitutor(m);
s.replace("${price}${x}"); // expected "5$X", actual "5${x}"
s.replace("${a}${b}");     // expected "$B",  actual "${b}"

Same fix should carry over: only accept an escape at or after the end of the last substitution. I can open a Commons Text PR for it with your new tests ported over, if that works for you. Thanks for merging, and for the extra tests.

@garydgregory

Copy link
Copy Markdown
Member

@alhudz Thank you, feel free to create a PR for Commons Text.

@garydgregory

Copy link
Copy Markdown
Member

Hello @alhudz Would you please provide a PR for Commons Text?

@alhudz

alhudz commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Opened apache/commons-text#786 with the same fix in StringSubstitutor and your tests from here ported over. The deprecated org.apache.commons.text.StrSubstitutor has the same check; I left it out of that PR but can add it if you want it covered as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants