Skip to content

Commit b6876e0

Browse files
committed
Limit escaping to only quotes and trailing backslashes
1 parent 42e63a4 commit b6876e0

File tree

2 files changed

+101
-28
lines changed

2 files changed

+101
-28
lines changed

lib/mix/lib/mix/utils.ex

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -482,28 +482,39 @@ defmodule Mix.Utils do
482482
end
483483

484484
defp quoted(data) do
485-
escaped_data =
486-
data
487-
|> to_string()
488-
# escape \
489-
|> String.replace("\\", "\\\\")
490-
# escape " (required in DOT quoted strings)
491-
|> String.replace("\"", "\\\"")
492-
# escape other special characters
493-
|> String.replace("\0", "\\0")
494-
|> String.replace("\a", "\\a")
495-
|> String.replace("\b", "\\b")
496-
|> String.replace("\d", "\\d")
497-
|> String.replace("\e", "\\e")
498-
|> String.replace("\f", "\\f")
499-
|> String.replace("\n", "\\n")
500-
|> String.replace("\r", "\\r")
501-
|> String.replace("\t", "\\t")
502-
|> String.replace("\v", "\\v")
503-
485+
string = to_string(data)
486+
escaped_data = escape_dot_string(string)
504487
[?", escaped_data, ?"]
505488
end
506489

490+
# Escape a string for DOT format according to GraphViz specification https://graphviz.org/doc/info/lang.html
491+
# - Only quotes need escaping
492+
# - String must not end with an odd number of backslashes (would escape the closing quote)
493+
defp escape_dot_string(string) do
494+
escape_dot_string(string, [], 0)
495+
end
496+
497+
defp escape_dot_string(<<>>, acc, backslash_count) do
498+
if rem(backslash_count, 2) == 1 do
499+
# Odd number of trailing backslashes - add one more to make it even
500+
[acc, "\\"]
501+
else
502+
acc
503+
end
504+
end
505+
506+
defp escape_dot_string(<<?"::utf8, rest::binary>>, acc, _backslash_count) do
507+
escape_dot_string(rest, [acc, "\\\""], 0)
508+
end
509+
510+
defp escape_dot_string(<<"\\"::utf8, rest::binary>>, acc, backslash_count) do
511+
escape_dot_string(rest, [acc, "\\"], backslash_count + 1)
512+
end
513+
514+
defp escape_dot_string(<<char::utf8, rest::binary>>, acc, _backslash_count) do
515+
escape_dot_string(rest, [acc, char], 0)
516+
end
517+
507518
@doc false
508519
@deprecated "Use Macro.underscore/1 instead"
509520
def underscore(value) do

lib/mix/test/mix/utils_test.exs

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -243,43 +243,105 @@ defmodule Mix.UtilsTest do
243243
end)
244244
end
245245

246-
test "escapes newlines" do
247-
in_tmp("dot_quotes", fn ->
246+
test "preserves newlines and other control characters" do
247+
in_tmp("dot_newlines", fn ->
248248
callback = fn node -> {{node, nil}, []} end
249249

250250
Mix.Utils.write_dot_graph!("graph.dot", "graph", ["foo \nbar\r\nbaz"], callback, [])
251251

252252
assert File.read!("graph.dot") == """
253253
digraph "graph" {
254-
"foo \\nbar\\r\\nbaz"
254+
"foo
255+
bar\r
256+
baz"
255257
}
256258
"""
257259
end)
258260
end
259261

260-
test "escapes backslashes" do
262+
test "handles backslashes correctly" do
261263
in_tmp("dot_backslashes", fn ->
262264
callback = fn node -> {{node, nil}, []} end
263265

264266
Mix.Utils.write_dot_graph!("graph.dot", "graph", ["foo\\bar"], callback, [])
265267

266268
assert File.read!("graph.dot") == """
267269
digraph "graph" {
268-
"foo\\\\bar"
270+
"foo\\bar"
269271
}
270272
"""
271273
end)
272274
end
273275

274-
test "escapes control characters" do
275-
in_tmp("dot_tabs", fn ->
276+
test "quote and backslash combinations" do
277+
in_tmp("dot_complex", fn ->
278+
callback = fn node -> {{node, nil}, []} end
279+
280+
test_cases = [
281+
# "fo"o" -> "fo\"o"
282+
{"fo\"o", "fo\\\"o"},
283+
# "fo\"o" -> "fo\\\"o"
284+
{"fo\\\"o", "fo\\\\\"o"},
285+
# "fo\o" -> "fo\o"
286+
{"fo\\o", "fo\\o"},
287+
# "fo\\o" -> "fo\\o"
288+
{"fo\\\\o", "fo\\\\o"},
289+
# "fo\\\o" -> "fo\\\o"
290+
{"fo\\\\\\o", "fo\\\\\\o"}
291+
]
292+
293+
Enum.each(test_cases, fn {input, expected} ->
294+
Mix.Utils.write_dot_graph!("graph.dot", "graph", [input], callback, [])
295+
content = File.read!("graph.dot")
296+
assert content == "digraph \"graph\" {\n \"#{expected}\"\n}\n"
297+
end)
298+
end)
299+
end
300+
301+
test "escapes backslash at end of string" do
302+
in_tmp("dot_end_backslash", fn ->
303+
callback = fn node -> {{node, nil}, []} end
304+
305+
test_cases = [
306+
# "fo\" -> "fo\\" (add backslash)
307+
{"fo\\", "fo\\\\"},
308+
# "fo\\" -> "fo\\" (already valid)
309+
{"fo\\\\", "fo\\\\"},
310+
# "fo\\\" -> "fo\\\\" (add backslash)
311+
{"fo\\\\\\", "fo\\\\\\\\"}
312+
]
313+
314+
Enum.each(test_cases, fn {input, expected} ->
315+
Mix.Utils.write_dot_graph!("graph.dot", "graph", [input], callback, [])
316+
content = File.read!("graph.dot")
317+
assert content == "digraph \"graph\" {\n \"#{expected}\"\n}\n"
318+
end)
319+
end)
320+
end
321+
322+
test "handles empty strings" do
323+
in_tmp("dot_empty", fn ->
276324
callback = fn node -> {{node, nil}, []} end
277325

278-
Mix.Utils.write_dot_graph!("graph.dot", "graph", ["foo\v\t\f\e\d\b\a\0bar"], callback, [])
326+
Mix.Utils.write_dot_graph!("graph.dot", "graph", [""], callback, [])
327+
328+
assert File.read!("graph.dot") == """
329+
digraph "graph" {
330+
""
331+
}
332+
"""
333+
end)
334+
end
335+
336+
test "handles edge labels with escaping" do
337+
in_tmp("dot_edge_labels", fn ->
338+
callback = fn node -> {{node, "edge \"label\""}, []} end
339+
340+
Mix.Utils.write_dot_graph!("graph.dot", "graph", ["node"], callback, [])
279341

280342
assert File.read!("graph.dot") == """
281343
digraph "graph" {
282-
"foo\\v\\t\\f\\e\\d\\b\\a\\0bar"
344+
"node" [label="edge \\"label\\""]
283345
}
284346
"""
285347
end)

0 commit comments

Comments
 (0)