From 638ab3eff6c494a5d8d6ba20e20eb76245fb13f4 Mon Sep 17 00:00:00 2001 From: brharrington Date: Sun, 25 Aug 2024 20:38:58 -0500 Subject: [PATCH] atlas: support unicode escapes in expressions (#1153) Update query parsing to support unicode escapes for special characters like comma. --- .../netflix/spectator/atlas/impl/Parser.java | 76 +++++++++++- .../netflix/spectator/atlas/impl/Query.java | 24 ++-- .../spectator/atlas/impl/ParserTest.java | 64 ++++++++++ .../spectator/atlas/impl/QueryTest.java | 115 +++++++++++++++++- 4 files changed, 266 insertions(+), 13 deletions(-) create mode 100644 spectator-reg-atlas/src/test/java/com/netflix/spectator/atlas/impl/ParserTest.java diff --git a/spectator-reg-atlas/src/main/java/com/netflix/spectator/atlas/impl/Parser.java b/spectator-reg-atlas/src/main/java/com/netflix/spectator/atlas/impl/Parser.java index a8c1a2790..f7245ebf9 100644 --- a/spectator-reg-atlas/src/main/java/com/netflix/spectator/atlas/impl/Parser.java +++ b/spectator-reg-atlas/src/main/java/com/netflix/spectator/atlas/impl/Parser.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2019 Netflix, Inc. + * Copyright 2014-2024 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -70,7 +70,7 @@ private static Object parse(String expr) { String[] parts = expr.split(","); Deque stack = new ArrayDeque<>(parts.length); for (String p : parts) { - String token = p.trim(); + String token = unescape(p.trim()); if (token.isEmpty()) { continue; } @@ -238,4 +238,76 @@ private static void pushIn(Deque stack, String k, List values) { else stack.push(new Query.In(k, new HashSet<>(values))); } + + static boolean isSpecial(int codePoint) { + return codePoint == ',' || Character.isWhitespace(codePoint); + } + + static void zeroPad(String str, StringBuilder builder) { + final int width = 4; + final int n = width - str.length(); + for (int i = 0; i < n; ++i) { + builder.append('0'); + } + builder.append(str); + } + + private static void escapeCodePoint(int codePoint, StringBuilder builder) { + builder.append("\\u"); + zeroPad(Integer.toHexString(codePoint), builder); + } + + /** + * Escape special characters in the input string to unicode escape sequences (uXXXX). + */ + @SuppressWarnings("PMD") + public static String escape(String str) { + final int length = str.length(); + StringBuilder builder = new StringBuilder(length); + for (int i = 0; i < length;) { + final int cp = str.codePointAt(i); + final int len = Character.charCount(cp); + if (isSpecial(cp)) + escapeCodePoint(cp, builder); + else + builder.appendCodePoint(cp); + i += len; + } + return builder.toString(); + } + + /** + * Unescape unicode characters in the input string. Ignore any invalid or unrecognized + * escape sequences. + */ + @SuppressWarnings("PMD") + public static String unescape(String str) { + final int length = str.length(); + StringBuilder builder = new StringBuilder(length); + for (int i = 0; i < length; ++i) { + final char c = str.charAt(i); + if (c == '\\') { + // Ensure there is enough space for an encoded character, there must be at + // least 5 characters left in the string (uXXXX). + if (length - i <= 5) { + builder.append(str.substring(i)); + i = length; + } else if (str.charAt(i + 1) == 'u') { + try { + int cp = Integer.parseInt(str.substring(i + 2, i + 6), 16); + builder.appendCodePoint(cp); + i += 5; + } catch (NumberFormatException e) { + builder.append(c); + } + } else { + // Some other escape, copy into buffer and move on + builder.append(c); + } + } else { + builder.append(c); + } + } + return builder.toString(); + } } diff --git a/spectator-reg-atlas/src/main/java/com/netflix/spectator/atlas/impl/Query.java b/spectator-reg-atlas/src/main/java/com/netflix/spectator/atlas/impl/Query.java index 6e4f7c202..787d1b98b 100644 --- a/spectator-reg-atlas/src/main/java/com/netflix/spectator/atlas/impl/Query.java +++ b/spectator-reg-atlas/src/main/java/com/netflix/spectator/atlas/impl/Query.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 Netflix, Inc. + * Copyright 2014-2024 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +27,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.StringJoiner; /** * Query for matching based on tags. For more information see @@ -497,7 +498,7 @@ final class Has implements KeyQuery { } @Override public String toString() { - return k + ",:has"; + return Parser.escape(k) + ",:has"; } @Override public boolean equals(Object obj) { @@ -542,7 +543,7 @@ public String value() { } @Override public String toString() { - return k + "," + v + ",:eq"; + return Parser.escape(k) + "," + Parser.escape(v) + ",:eq"; } @Override public boolean equals(Object obj) { @@ -609,8 +610,11 @@ public Set values() { } @Override public String toString() { - String values = String.join(",", vs); - return k + ",(," + values + ",),:in"; + StringJoiner joiner = new StringJoiner(","); + for (String v : vs) { + joiner.add(Parser.escape(v)); + } + return Parser.escape(k) + ",(," + joiner + ",),:in"; } @Override public boolean equals(Object obj) { @@ -654,7 +658,7 @@ final class LessThan implements KeyQuery { } @Override public String toString() { - return k + "," + v + ",:lt"; + return Parser.escape(k) + "," + Parser.escape(v) + ",:lt"; } @Override public boolean equals(Object obj) { @@ -694,7 +698,7 @@ final class LessThanEqual implements KeyQuery { } @Override public String toString() { - return k + "," + v + ",:le"; + return Parser.escape(k) + "," + Parser.escape(v) + ",:le"; } @Override public boolean equals(Object obj) { @@ -734,7 +738,7 @@ final class GreaterThan implements KeyQuery { } @Override public String toString() { - return k + "," + v + ",:gt"; + return Parser.escape(k) + "," + Parser.escape(v) + ",:gt"; } @Override public boolean equals(Object obj) { @@ -774,7 +778,7 @@ final class GreaterThanEqual implements KeyQuery { } @Override public String toString() { - return k + "," + v + ",:ge"; + return Parser.escape(k) + "," + Parser.escape(v) + ",:ge"; } @Override public boolean equals(Object obj) { @@ -841,7 +845,7 @@ public boolean alwaysMatches() { } @Override public String toString() { - return k + "," + v + "," + name; + return Parser.escape(k) + "," + Parser.escape(v) + "," + name; } @Override public boolean equals(Object obj) { diff --git a/spectator-reg-atlas/src/test/java/com/netflix/spectator/atlas/impl/ParserTest.java b/spectator-reg-atlas/src/test/java/com/netflix/spectator/atlas/impl/ParserTest.java new file mode 100644 index 000000000..d83d3ff5f --- /dev/null +++ b/spectator-reg-atlas/src/test/java/com/netflix/spectator/atlas/impl/ParserTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2014-2024 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spectator.atlas.impl; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ParserTest { + + private static String zeroPad(int i) { + StringBuilder builder = new StringBuilder(); + Parser.zeroPad(Integer.toHexString(i), builder); + return builder.toString(); + } + + @Test + public void escape() { + for (char i = 0; i < Short.MAX_VALUE; ++i) { + String str = Character.toString(i); + String expected = Parser.isSpecial(i) ? "\\u" + zeroPad(i) : str; + Assertions.assertEquals(expected, Parser.escape(str)); + } + } + + @Test + public void unescape() { + for (char i = 0; i < Short.MAX_VALUE; ++i) { + String str = Character.toString(i); + String escaped = "\\u" + zeroPad(i); + Assertions.assertEquals(str, Parser.unescape(escaped)); + } + } + + @Test + public void unescapeTooShort() { + String str = "foo\\u000"; + Assertions.assertEquals(str, Parser.unescape(str)); + } + + @Test + public void unescapeUnknownType() { + String str = "foo\\x0000"; + Assertions.assertEquals(str, Parser.unescape(str)); + } + + @Test + public void unescapeInvalid() { + String str = "foo\\uzyff"; + Assertions.assertEquals(str, Parser.unescape(str)); + } +} diff --git a/spectator-reg-atlas/src/test/java/com/netflix/spectator/atlas/impl/QueryTest.java b/spectator-reg-atlas/src/test/java/com/netflix/spectator/atlas/impl/QueryTest.java index 0d5cb0e93..ea8e186bf 100644 --- a/spectator-reg-atlas/src/test/java/com/netflix/spectator/atlas/impl/QueryTest.java +++ b/spectator-reg-atlas/src/test/java/com/netflix/spectator/atlas/impl/QueryTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2021 Netflix, Inc. + * Copyright 2014-2024 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -625,4 +625,117 @@ public void simplifyFalse() { Query q = Parser.parseQuery(":false"); Assertions.assertSame(q, q.simplify(tags("nf.cluster", "foo"))); } + + @Test + public void keysAndValuesWithSpecialChars() { + String[] ops = {"eq", "lt", "le", "gt", "ge"}; + for (String op : ops) { + String k = "foo\\u002cbar"; + String v = "a\\u002cb\\u002cc"; + String expr = k + "," + v + ",:" + op; + Query q = Parser.parseQuery(expr); + Assertions.assertEquals(expr, q.toString()); + + Query.KeyQuery kq = (Query.KeyQuery) q; + Assertions.assertEquals("foo,bar", kq.key()); + if ("lt".equals(op)) { + Assertions.assertTrue(kq.matches("a,b,b")); + } else if ("gt".equals(op)) { + Assertions.assertTrue(kq.matches("a,b,d")); + } else { + Assertions.assertTrue(kq.matches("a,b,c")); + } + } + } + + @Test + public void inClauseWithSpecialChars() { + String k = "foo\\u002cbar"; + String vs = "(,a\\u002cb\\u002cc,d,)"; + String expr = k + "," + vs + ",:in"; + Query q = Parser.parseQuery(expr); + Assertions.assertEquals(expr, q.toString()); + + Query.In in = (Query.In) q; + Assertions.assertEquals("foo,bar", in.key()); + Assertions.assertTrue(in.matches("a,b,c")); + Assertions.assertTrue(in.matches("d")); + } + + @Test + public void hasWithSpecialChars() { + String k = "foo\\u002cbar"; + String expr = k + ",:has"; + Query q = Parser.parseQuery(expr); + Assertions.assertEquals(expr, q.toString()); + + Query.Has has = (Query.Has) q; + Assertions.assertEquals("foo,bar", has.key()); + } + + @Test + public void reWithSpecialChars() { + String k = "foo\\u002cbar"; + String v = "a\\u002cb\\u002cc"; + String expr = k + "," + v + ",:re"; + Query q = Parser.parseQuery(expr); + Assertions.assertEquals(expr, q.toString()); + + Query.Regex re = (Query.Regex) q; + Assertions.assertEquals("foo,bar", re.key()); + Assertions.assertTrue(re.matches("a,b,c")); + } + + @Test + public void reicWithSpecialChars() { + String k = "foo\\u002cbar"; + String v = "a\\u002cb\\u002cc"; + String expr = k + "," + v + ",:reic"; + Query q = Parser.parseQuery(expr); + Assertions.assertEquals(expr, q.toString()); + + Query.Regex re = (Query.Regex) q; + Assertions.assertEquals("foo,bar", re.key()); + Assertions.assertTrue(re.matches("a,b,c")); + Assertions.assertTrue(re.matches("a,B,c")); + } + + @Test + public void startsWithSpecialChars() { + String k = "foo\\u002cbar"; + String v = "a\\u002cb\\u002cc"; + String expr = k + "," + v + ",:starts"; + Query q = Parser.parseQuery(expr); + Assertions.assertEquals(k + "," + v + ",:re", q.toString()); + + Query.Regex re = (Query.Regex) q; + Assertions.assertEquals("foo,bar", re.key()); + Assertions.assertTrue(re.matches("a,b,c")); + } + + @Test + public void endsWithSpecialChars() { + String k = "foo\\u002cbar"; + String v = "a\\u002cb\\u002cc"; + String expr = k + "," + v + ",:ends"; + Query q = Parser.parseQuery(expr); + Assertions.assertEquals(k + ",.*" + v + "$,:re", q.toString()); + + Query.Regex re = (Query.Regex) q; + Assertions.assertEquals("foo,bar", re.key()); + Assertions.assertTrue(re.matches("a,b,c")); + } + + @Test + public void containsWithSpecialChars() { + String k = "foo\\u002cbar"; + String v = "a\\u002cb\\u002cc"; + String expr = k + "," + v + ",:contains"; + Query q = Parser.parseQuery(expr); + Assertions.assertEquals(k + ",.*" + v + ",:re", q.toString()); + + Query.Regex re = (Query.Regex) q; + Assertions.assertEquals("foo,bar", re.key()); + Assertions.assertTrue(re.matches("a,b,c")); + } }