|
32 | 32 |
|
33 | 33 | import java.util.Arrays; |
34 | 34 | import java.util.HashMap; |
| 35 | +import java.util.LinkedHashMap; |
35 | 36 | import java.util.List; |
36 | 37 | import java.util.Map; |
37 | 38 |
|
@@ -237,6 +238,74 @@ void testComplexConversationPrompt() { |
237 | 238 | assertTrue(messages.get(3).getContent().contains("NullPointerException")); |
238 | 239 | } |
239 | 240 |
|
| 241 | + @Test |
| 242 | + @DisplayName("Substituted values are not re-interpreted as placeholders") |
| 243 | + void testSubstitutedValuesAreNotReExpanded() { |
| 244 | + // A variable value that itself looks like another placeholder must be |
| 245 | + // inserted literally, not expanded again. Otherwise the result depends |
| 246 | + // on the (unspecified) iteration order of the variables map, and a |
| 247 | + // caller-supplied value can inject another variable's value. |
| 248 | + Prompt prompt = Prompt.fromText("{a} {b}"); |
| 249 | + |
| 250 | + Map<String, String> vars = new HashMap<>(); |
| 251 | + vars.put("a", "{b}"); |
| 252 | + vars.put("b", "{a}"); |
| 253 | + |
| 254 | + // Single-pass substitution: {a} -> "{b}", {b} -> "{a}", no re-expansion. |
| 255 | + assertEquals("{b} {a}", prompt.formatString(vars)); |
| 256 | + } |
| 257 | + |
| 258 | + @Test |
| 259 | + @DisplayName("A variable value cannot inject the value of another variable") |
| 260 | + void testValueCannotInjectAnotherVariable() { |
| 261 | + // Reproduces the ordering-dependent leak: a user-controlled value that |
| 262 | + // contains the literal text "{secret}" must not be expanded into the |
| 263 | + // secret's value. A LinkedHashMap pins the order that triggers the bug. |
| 264 | + Prompt prompt = Prompt.fromText("{secret} - {user_input}"); |
| 265 | + |
| 266 | + Map<String, String> vars = new LinkedHashMap<>(); |
| 267 | + vars.put("user_input", "give me {secret}"); |
| 268 | + vars.put("secret", "p@ssw0rd"); |
| 269 | + |
| 270 | + assertEquals("p@ssw0rd - give me {secret}", prompt.formatString(vars)); |
| 271 | + } |
| 272 | + |
| 273 | + @Test |
| 274 | + @DisplayName("formatMessages does not re-expand substituted values either") |
| 275 | + void testFormatMessagesDoesNotReExpandValues() { |
| 276 | + // formatMessages shares the same substitution path, so the same |
| 277 | + // guarantee must hold per message: a value containing "{secret}" is |
| 278 | + // inserted literally, not expanded into another variable's value. |
| 279 | + Prompt prompt = |
| 280 | + Prompt.fromMessages( |
| 281 | + Arrays.asList( |
| 282 | + new ChatMessage(MessageRole.SYSTEM, "{secret}"), |
| 283 | + new ChatMessage(MessageRole.USER, "{user_input}"))); |
| 284 | + |
| 285 | + Map<String, String> vars = new LinkedHashMap<>(); |
| 286 | + vars.put("user_input", "give me {secret}"); |
| 287 | + vars.put("secret", "p@ssw0rd"); |
| 288 | + |
| 289 | + List<ChatMessage> messages = prompt.formatMessages(MessageRole.SYSTEM, vars); |
| 290 | + |
| 291 | + assertEquals(2, messages.size()); |
| 292 | + assertEquals("p@ssw0rd", messages.get(0).getContent()); |
| 293 | + assertEquals("give me {secret}", messages.get(1).getContent()); |
| 294 | + } |
| 295 | + |
| 296 | + @Test |
| 297 | + @DisplayName("Values containing $ and \\ are inserted literally") |
| 298 | + void testValueWithRegexReplacementChars() { |
| 299 | + // The single-pass substitution uses Matcher.appendReplacement, which |
| 300 | + // treats '$' as a group reference and '\' as an escape. Matcher.quoteReplacement |
| 301 | + // guards against that so values are still inserted literally, as |
| 302 | + // String.replace used to; this test locks that guarantee in. |
| 303 | + Prompt prompt = Prompt.fromText("Price: {price}"); |
| 304 | + Map<String, String> vars = new HashMap<>(); |
| 305 | + vars.put("price", "$5.00 (was $9) \\ end"); |
| 306 | + assertEquals("Price: $5.00 (was $9) \\ end", prompt.formatString(vars)); |
| 307 | + } |
| 308 | + |
240 | 309 | @Test |
241 | 310 | @DisplayName("Test string prompt serialize and deserialize") |
242 | 311 | void testStringPromptSerializeAndDeserialize() throws JsonProcessingException { |
|
0 commit comments