diff --git a/common/src/main/java/io/netty/util/DomainMappingBuilder.java b/common/src/main/java/io/netty/util/DomainMappingBuilder.java new file mode 100644 index 0000000000..0cdb9712b7 --- /dev/null +++ b/common/src/main/java/io/netty/util/DomainMappingBuilder.java @@ -0,0 +1,191 @@ +/* + * Copyright 2015 The Netty Project + * + * The Netty Project licenses this file to you 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 io.netty.util; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import static io.netty.util.internal.ObjectUtil.checkNotNull; + +/** + * Builder for immutable {@link DomainNameMapping} instances. + * + * @param concrete type of value objects + */ +public final class DomainMappingBuilder { + + private final V defaultValue; + private final Map map; + + /** + * Constructor with default initial capacity of the map holding the mappings + * + * @param defaultValue the default value for {@link DomainNameMapping#map(String)} to return + * when nothing matches the input + */ + public DomainMappingBuilder(V defaultValue) { + this(4, defaultValue); + } + + /** + * Constructor with initial capacity of the map holding the mappings + * + * @param initialCapacity initial capacity for the internal map + * @param defaultValue the default value for {@link DomainNameMapping#map(String)} to return + * when nothing matches the input + */ + public DomainMappingBuilder(int initialCapacity, V defaultValue) { + this.defaultValue = checkNotNull(defaultValue, "defaultValue"); + this.map = new LinkedHashMap(initialCapacity); + } + + /** + * Adds a mapping that maps the specified (optionally wildcard) host name to the specified output value. + * Null values are forbidden for both hostnames and values. + *

+ * DNS wildcard is supported as hostname. + * For example, you can use {@code *.netty.io} to match {@code netty.io} and {@code downloads.netty.io}. + *

+ * + * @param hostname the host name (optionally wildcard) + * @param output the output value that will be returned by {@link DomainNameMapping#map(String)} + * when the specified host name matches the specified input host name + */ + public DomainMappingBuilder add(String hostname, V output) { + map.put(checkNotNull(hostname, "hostname"), checkNotNull(output, "output")); + return this; + } + + /** + * Creates a new instance of immutable {@link DomainNameMapping} + * Attempts to add new mappings to the result object will cause {@link UnsupportedOperationException} to be thrown + * + * @return new {@link DomainNameMapping} instance + */ + public DomainNameMapping build() { + return new ImmutableDomainNameMapping(this.defaultValue, this.map); + } + + /** + * Immutable mapping from domain name pattern to its associated value object. + * Mapping is represented by two arrays: keys and values. Key domainNamePatterns[i] is associated with values[i]. + * + * @param concrete type of value objects + */ + private static final class ImmutableDomainNameMapping extends DomainNameMapping { + private static final String REPR_HEADER = "ImmutableDomainNameMapping(default: "; + private static final String REPR_MAP_OPENING = ", map: {"; + private static final String REPR_MAP_CLOSING = "})"; + private static final int REPR_CONST_PART_LENGTH = + REPR_HEADER.length() + REPR_MAP_OPENING.length() + REPR_MAP_CLOSING.length(); + + private final String[] domainNamePatterns; + private final V[] values; + + @SuppressWarnings("unchecked") + private ImmutableDomainNameMapping(V defaultValue, Map map) { + super(null, defaultValue); + + Set> mappings = map.entrySet(); + int numberOfMappings = mappings.size(); + domainNamePatterns = new String[numberOfMappings]; + values = (V[]) new Object[numberOfMappings]; + + int index = 0; + for (Map.Entry mapping : mappings) { + domainNamePatterns[index] = normalizeHostname(mapping.getKey()); + values[index] = mapping.getValue(); + ++index; + } + } + + @Override + @Deprecated + public DomainNameMapping add(String hostname, V output) { + throw new UnsupportedOperationException( + "Immutable DomainNameMapping does not support modification after initial creation"); + } + + @Override + public V map(String hostname) { + if (hostname != null) { + hostname = normalizeHostname(hostname); + + int length = domainNamePatterns.length; + for (int index = 0; index < length; ++index) { + if (matches(domainNamePatterns[index], hostname)) { + return values[index]; + } + } + } + + return defaultValue; + } + + @Override + public String toString() { + String defaultValueStr = defaultValue.toString(); + + int numberOfMappings = domainNamePatterns.length; + if (numberOfMappings == 0) { + return REPR_HEADER + defaultValueStr + REPR_MAP_OPENING + REPR_MAP_CLOSING; + } + + String pattern0 = domainNamePatterns[0]; + String value0 = values[0].toString(); + int oneMappingLength = pattern0.length() + value0.length() + 3; // 2 for separator ", " and 1 for '=' + int estimatedBufferSize = estimateBufferSize(defaultValueStr.length(), numberOfMappings, oneMappingLength); + + StringBuilder sb = new StringBuilder(estimatedBufferSize) + .append(REPR_HEADER).append(defaultValueStr).append(REPR_MAP_OPENING); + + appendMapping(sb, pattern0, value0); + for (int index = 1; index < numberOfMappings; ++index) { + sb.append(", "); + appendMapping(sb, index); + } + + return sb.append(REPR_MAP_CLOSING).toString(); + } + + /** + * Estimates the length of string representation of the given instance: + * est = lengthOfConstantComponents + defaultValueLength + (estimatedMappingLength * numOfMappings) * 1.10 + * + * @param defaultValueLength length of string representation of {@link #defaultValue} + * @param numberOfMappings number of mappings the given instance holds, + * e.g. {@link #domainNamePatterns#length} + * @param estimatedMappingLength estimated size taken by one mapping + * @return estimated length of string returned by {@link #toString()} + */ + private static int estimateBufferSize(int defaultValueLength, + int numberOfMappings, + int estimatedMappingLength) { + return REPR_CONST_PART_LENGTH + defaultValueLength + + (int) (estimatedMappingLength * numberOfMappings * 1.10); + } + + private StringBuilder appendMapping(StringBuilder sb, int mappingIndex) { + return appendMapping(sb, domainNamePatterns[mappingIndex], values[mappingIndex].toString()); + } + + private static StringBuilder appendMapping(StringBuilder sb, String domainNamePattern, String value) { + return sb.append(domainNamePattern).append('=').append(value); + } + } +} diff --git a/common/src/main/java/io/netty/util/DomainNameMapping.java b/common/src/main/java/io/netty/util/DomainNameMapping.java index b3cf039367..ff9aeb9cb0 100644 --- a/common/src/main/java/io/netty/util/DomainNameMapping.java +++ b/common/src/main/java/io/netty/util/DomainNameMapping.java @@ -22,7 +22,9 @@ import java.net.IDN; import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; -import java.util.regex.Pattern; + +import static io.netty.util.internal.ObjectUtil.checkNotNull; +import static io.netty.util.internal.StringUtil.commonSuffixOfLength; /** * Maps a domain name to its associated value object. @@ -33,18 +35,17 @@ import java.util.regex.Pattern; */ public class DomainNameMapping implements Mapping { - private static final Pattern DNS_WILDCARD_PATTERN = Pattern.compile("^\\*\\..*"); - + final V defaultValue; private final Map map; - private final V defaultValue; - /** * Creates a default, order-sensitive mapping. If your hostnames are in conflict, the mapping * will choose the one you add first. * * @param defaultValue the default value for {@link #map(String)} to return when nothing matches the input + * @deprecated use {@link DomainMappingBuilder} to create and fill the mapping instead */ + @Deprecated public DomainNameMapping(V defaultValue) { this(4, defaultValue); } @@ -54,14 +55,17 @@ public class DomainNameMapping implements Mapping { * will choose the one you add first. * * @param initialCapacity initial capacity for the internal map - * @param defaultValue the default value for {@link #map(String)} to return when nothing matches the input + * @param defaultValue the default value for {@link #map(String)} to return when nothing matches the input + * @deprecated use {@link DomainMappingBuilder} to create and fill the mapping instead */ + @Deprecated public DomainNameMapping(int initialCapacity, V defaultValue) { - if (defaultValue == null) { - throw new NullPointerException("defaultValue"); - } - map = new LinkedHashMap(initialCapacity); - this.defaultValue = defaultValue; + this(new LinkedHashMap(initialCapacity), defaultValue); + } + + DomainNameMapping(Map map, V defaultValue) { + this.defaultValue = checkNotNull(defaultValue, "defaultValue"); + this.map = map; } /** @@ -72,39 +76,31 @@ public class DomainNameMapping implements Mapping { *

* * @param hostname the host name (optionally wildcard) - * @param output the output value that will be returned by {@link #map(String)} when the specified host name - * matches the specified input host name + * @param output the output value that will be returned by {@link #map(String)} when the specified host name + * matches the specified input host name + * @deprecated use {@link DomainMappingBuilder} to create and fill the mapping instead */ + @Deprecated public DomainNameMapping add(String hostname, V output) { - if (hostname == null) { - throw new NullPointerException("input"); - } - - if (output == null) { - throw new NullPointerException("output"); - } - - map.put(normalizeHostname(hostname), output); + map.put(normalizeHostname(checkNotNull(hostname, "hostname")), checkNotNull(output, "output")); return this; } /** * Simple function to match DNS wildcard. */ - private static boolean matches(String hostNameTemplate, String hostName) { - // note that inputs are converted and lowercased already - if (DNS_WILDCARD_PATTERN.matcher(hostNameTemplate).matches()) { - return hostNameTemplate.substring(2).equals(hostName) || - hostName.endsWith(hostNameTemplate.substring(1)); - } else { - return hostNameTemplate.equals(hostName); + static boolean matches(String template, String hostName) { + if (template.startsWith("*.")) { + return template.regionMatches(2, hostName, 0, hostName.length()) + || commonSuffixOfLength(hostName, template, template.length() - 1); } + return template.equals(hostName); } /** * IDNA ASCII conversion and case normalization */ - private static String normalizeHostname(String hostname) { + static String normalizeHostname(String hostname) { if (needsNormalization(hostname)) { hostname = IDN.toASCII(hostname, IDN.ALLOW_UNASSIGNED); } @@ -113,7 +109,7 @@ public class DomainNameMapping implements Mapping { private static boolean needsNormalization(String hostname) { final int length = hostname.length(); - for (int i = 0; i < length; i ++) { + for (int i = 0; i < length; i++) { int c = hostname.charAt(i); if (c > 0x7F) { return true; @@ -123,17 +119,16 @@ public class DomainNameMapping implements Mapping { } @Override - public V map(String input) { - if (input != null) { - input = normalizeHostname(input); + public V map(String hostname) { + if (hostname != null) { + hostname = normalizeHostname(hostname); for (Map.Entry entry : map.entrySet()) { - if (matches(entry.getKey(), input)) { + if (matches(entry.getKey(), hostname)) { return entry.getValue(); } } } - return defaultValue; } diff --git a/common/src/main/java/io/netty/util/internal/StringUtil.java b/common/src/main/java/io/netty/util/internal/StringUtil.java index 6764caeddb..eb1adc6854 100644 --- a/common/src/main/java/io/netty/util/internal/StringUtil.java +++ b/common/src/main/java/io/netty/util/internal/StringUtil.java @@ -183,6 +183,18 @@ public final class StringUtil { return null; } + /** + * Checks if two strings have the same suffix of specified length + * + * @param s string + * @param p string + * @param len length of the common suffix + * @return true if both s and p are not null and both have the same suffix. Otherwise - false + */ + public static boolean commonSuffixOfLength(String s, String p, int len) { + return s != null && p != null && len >= 0 && s.regionMatches(s.length() - len, p, p.length() - len, len); + } + /** * Converts the specified byte value into a 2-digit hexadecimal integer. */ diff --git a/common/src/test/java/io/netty/util/DomainNameMappingTest.java b/common/src/test/java/io/netty/util/DomainNameMappingTest.java new file mode 100644 index 0000000000..bd369e9301 --- /dev/null +++ b/common/src/test/java/io/netty/util/DomainNameMappingTest.java @@ -0,0 +1,184 @@ +/* +* Copyright 2015 The Netty Project +* +* The Netty Project licenses this file to you 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 io.netty.util; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("deprecation") +public class DomainNameMappingTest { + + // Deprecated API + + @Test(expected = NullPointerException.class) + public void testNullDefaultValueInDeprecatedApi() { + new DomainNameMapping(null); + } + + @Test(expected = NullPointerException.class) + public void testNullDomainNamePatternsAreForbiddenInDeprecatedApi() { + new DomainNameMapping("NotFound").add(null, "Some value"); + } + + @Test(expected = NullPointerException.class) + public void testNullValuesAreForbiddenInDeprecatedApi() { + new DomainNameMapping("NotFound").add("Some key", null); + } + + @Test + public void testDefaultValueInDeprecatedApi() { + DomainNameMapping mapping = new DomainNameMapping("NotFound"); + + assertEquals("NotFound", mapping.map("not-existing")); + + mapping.add("*.netty.io", "Netty"); + + assertEquals("NotFound", mapping.map("not-existing")); + } + + @Test + public void testStrictEqualityInDeprecatedApi() { + DomainNameMapping mapping = new DomainNameMapping("NotFound") + .add("netty.io", "Netty") + .add("downloads.netty.io", "Netty-Downloads"); + + assertEquals("Netty", mapping.map("netty.io")); + assertEquals("Netty-Downloads", mapping.map("downloads.netty.io")); + + assertEquals("NotFound", mapping.map("x.y.z.netty.io")); + } + + @Test + public void testWildcardMatchesAnyPrefixInDeprecatedApi() { + DomainNameMapping mapping = new DomainNameMapping("NotFound") + .add("*.netty.io", "Netty"); + + assertEquals("Netty", mapping.map("netty.io")); + assertEquals("Netty", mapping.map("downloads.netty.io")); + assertEquals("Netty", mapping.map("x.y.z.netty.io")); + + assertEquals("NotFound", mapping.map("netty.io.x")); + } + + @Test + public void testFirstMatchWinsInDeprecatedApi() { + assertEquals("Netty", + new DomainNameMapping("NotFound") + .add("*.netty.io", "Netty") + .add("downloads.netty.io", "Netty-Downloads") + .map("downloads.netty.io")); + + assertEquals("Netty-Downloads", + new DomainNameMapping("NotFound") + .add("downloads.netty.io", "Netty-Downloads") + .add("*.netty.io", "Netty") + .map("downloads.netty.io")); + } + + @Test + public void testToStringInDeprecatedApi() { + DomainNameMapping mapping = new DomainNameMapping("NotFound") + .add("*.netty.io", "Netty") + .add("downloads.netty.io", "Netty-Downloads"); + + assertEquals( + "DomainNameMapping(default: NotFound, map: {*.netty.io=Netty, downloads.netty.io=Netty-Downloads})", + mapping.toString()); + } + + // Immutable DomainNameMapping Builder API + + @Test(expected = NullPointerException.class) + public void testNullDefaultValue() { + new DomainMappingBuilder(null); + } + + @Test(expected = NullPointerException.class) + public void testNullDomainNamePatternsAreForbidden() { + new DomainMappingBuilder("NotFound").add(null, "Some value"); + } + + @Test(expected = NullPointerException.class) + public void testNullValuesAreForbidden() { + new DomainMappingBuilder("NotFound").add("Some key", null); + } + + @Test + public void testDefaultValue() { + DomainNameMapping mapping = new DomainMappingBuilder("NotFound") + .add("*.netty.io", "Netty") + .build(); + + assertEquals("NotFound", mapping.map("not-existing")); + } + + @Test + public void testStrictEquality() { + DomainNameMapping mapping = new DomainMappingBuilder("NotFound") + .add("netty.io", "Netty") + .add("downloads.netty.io", "Netty-Downloads") + .build(); + + assertEquals("Netty", mapping.map("netty.io")); + assertEquals("Netty-Downloads", mapping.map("downloads.netty.io")); + + assertEquals("NotFound", mapping.map("x.y.z.netty.io")); + } + + @Test + public void testWildcardMatchesAnyPrefix() { + DomainNameMapping mapping = new DomainMappingBuilder("NotFound") + .add("*.netty.io", "Netty") + .build(); + + assertEquals("Netty", mapping.map("netty.io")); + assertEquals("Netty", mapping.map("downloads.netty.io")); + assertEquals("Netty", mapping.map("x.y.z.netty.io")); + + assertEquals("NotFound", mapping.map("netty.io.x")); + } + + @Test + public void testFirstMatchWins() { + assertEquals("Netty", + new DomainMappingBuilder("NotFound") + .add("*.netty.io", "Netty") + .add("downloads.netty.io", "Netty-Downloads") + .build() + .map("downloads.netty.io")); + + assertEquals("Netty-Downloads", + new DomainMappingBuilder("NotFound") + .add("downloads.netty.io", "Netty-Downloads") + .add("*.netty.io", "Netty") + .build() + .map("downloads.netty.io")); + } + + @Test + public void testToString() { + DomainNameMapping mapping = new DomainMappingBuilder("NotFound") + .add("*.netty.io", "Netty") + .add("downloads.netty.io", "Netty-Download") + .build(); + + assertEquals( + "ImmutableDomainNameMapping(default: NotFound, map: {*.netty.io=Netty, downloads.netty.io=Netty-Download})", + mapping.toString()); + } +} diff --git a/common/src/test/java/io/netty/util/internal/StringUtilTest.java b/common/src/test/java/io/netty/util/internal/StringUtilTest.java index 7e15652ac8..66229dacb1 100644 --- a/common/src/test/java/io/netty/util/internal/StringUtilTest.java +++ b/common/src/test/java/io/netty/util/internal/StringUtilTest.java @@ -82,6 +82,245 @@ public class StringUtilTest { assertEquals("bar:bar2", substringAfter("foo:bar:bar2", ':')); } + @Test + public void commonSuffixOfLengthTest() { + // negative length suffixes are never common + checkNotCommonSuffix("abc", "abc", -1); + + // null has no suffix + checkNotCommonSuffix("abc", null, 0); + checkNotCommonSuffix(null, null, 0); + + // any non-null string has 0-length suffix + checkCommonSuffix("abc", "xx", 0); + + checkCommonSuffix("abc", "abc", 0); + checkCommonSuffix("abc", "abc", 1); + checkCommonSuffix("abc", "abc", 2); + checkCommonSuffix("abc", "abc", 3); + checkNotCommonSuffix("abc", "abc", 4); + + checkCommonSuffix("abcd", "cd", 1); + checkCommonSuffix("abcd", "cd", 2); + checkNotCommonSuffix("abcd", "cd", 3); + + checkCommonSuffix("abcd", "axcd", 1); + checkCommonSuffix("abcd", "axcd", 2); + checkNotCommonSuffix("abcd", "axcd", 3); + + checkNotCommonSuffix("abcx", "abcy", 1); + } + + private static void checkNotCommonSuffix(String s, String p, int len) { + assertFalse(checkCommonSuffixSymmetric(s, p, len)); + } + + private static void checkCommonSuffix(String s, String p, int len) { + assertTrue(checkCommonSuffixSymmetric(s, p, len)); + } + + private static boolean checkCommonSuffixSymmetric(String s, String p, int len) { + boolean sp = commonSuffixOfLength(s, p, len); + boolean ps = commonSuffixOfLength(p, s, len); + assertEquals(sp, ps); + return sp; + } + + @Test (expected = NullPointerException.class) + public void escapeCsvNull() { + StringUtil.escapeCsv(null); + } + + @Test + public void escapeCsvEmpty() { + CharSequence value = ""; + CharSequence expected = value; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvUnquoted() { + CharSequence value = "something"; + CharSequence expected = value; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvAlreadyQuoted() { + CharSequence value = "\"something\""; + CharSequence expected = "\"something\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithQuote() { + CharSequence value = "s\""; + CharSequence expected = "\"s\"\"\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithQuoteInMiddle() { + CharSequence value = "some text\"and more text"; + CharSequence expected = "\"some text\"\"and more text\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithQuoteInMiddleAlreadyQuoted() { + CharSequence value = "\"some text\"and more text\""; + CharSequence expected = "\"some text\"\"and more text\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithQuotedWords() { + CharSequence value = "\"foo\"\"goo\""; + CharSequence expected = "\"foo\"\"goo\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithAlreadyEscapedQuote() { + CharSequence value = "foo\"\"goo"; + CharSequence expected = "foo\"\"goo"; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvEndingWithQuote() { + CharSequence value = "some\""; + CharSequence expected = "\"some\"\"\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithSingleQuote() { + CharSequence value = "\""; + CharSequence expected = "\"\"\"\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithSingleQuoteAndCharacter() { + CharSequence value = "\"f"; + CharSequence expected = "\"\"\"f\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvAlreadyEscapedQuote() { + CharSequence value = "\"some\"\""; + CharSequence expected = "\"some\"\"\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvQuoted() { + CharSequence value = "\"foo,goo\""; + CharSequence expected = value; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithLineFeed() { + CharSequence value = "some text\n more text"; + CharSequence expected = "\"some text\n more text\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithSingleLineFeedCharacter() { + CharSequence value = "\n"; + CharSequence expected = "\"\n\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithMultipleLineFeedCharacter() { + CharSequence value = "\n\n"; + CharSequence expected = "\"\n\n\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithQuotedAndLineFeedCharacter() { + CharSequence value = " \" \n "; + CharSequence expected = "\" \"\" \n \""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithLineFeedAtEnd() { + CharSequence value = "testing\n"; + CharSequence expected = "\"testing\n\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithComma() { + CharSequence value = "test,ing"; + CharSequence expected = "\"test,ing\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithSingleComma() { + CharSequence value = ","; + CharSequence expected = "\",\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithSingleCarriageReturn() { + CharSequence value = "\r"; + CharSequence expected = "\"\r\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithMultipleCarriageReturn() { + CharSequence value = "\r\r"; + CharSequence expected = "\"\r\r\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithCarriageReturn() { + CharSequence value = "some text\r more text"; + CharSequence expected = "\"some text\r more text\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithQuotedAndCarriageReturnCharacter() { + CharSequence value = "\"\r"; + CharSequence expected = "\"\"\"\r\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithCarriageReturnAtEnd() { + CharSequence value = "testing\r"; + CharSequence expected = "\"testing\r\""; + escapeCsv(value, expected); + } + + @Test + public void escapeCsvWithCRLFCharacter() { + CharSequence value = "\r\n"; + CharSequence expected = "\"\r\n\""; + escapeCsv(value, expected); + } + + private static void escapeCsv(CharSequence value, CharSequence expected) { + CharSequence escapedValue = value; + for (int i = 0; i < 10; ++i) { + escapedValue = StringUtil.escapeCsv(escapedValue); + assertEquals(expected, escapedValue.toString()); + } + } + @Test public void testSimpleClassName() throws Exception { testSimpleClassName(String.class);