From 444c5a5d9ebf958aa5e834841455bf74384b9297 Mon Sep 17 00:00:00 2001
From: Jonathan Hedley ASCII case conversion changes only A-Z; Unicode conversion uses {@link Locale#ROOT}.
+ * Neither trims, and the string lowercasing methods return an empty string for null input.
- * Attribute name and value comparisons are generally case sensitive. By default for HTML, attribute names are + * Attribute name and value comparisons are generally case-sensitive. By default for HTML, attribute names are * normalized to lower-case on parsing. That means you should use lower-case strings when referring to attributes by - * name. + * name. Case-insensitive name comparisons fold only ASCII letters; non-ASCII characters are compared as written. *
* * @author Jonathan Hedley, jonathan@hedley.net @@ -116,7 +117,7 @@ private int visibleIndex(int index) { private int indexOfKeyIgnoreCase(String key) { Validate.notNull(key); for (int i = 0; i < size; i++) { - if (key.equalsIgnoreCase(keys[i])) + if (equalsIgnoreAsciiCase(key, keys[i])) return i; } return NotFound; @@ -598,12 +599,12 @@ private SetFrom an Element, you can extract data, traverse the node graph, and manipulate the HTML. */ @@ -168,9 +169,8 @@ public String tagName() { } /** - * Get the normalized name of this Element's tag. This will always be the lower-cased version of the tag, regardless - * of the tag case preserving setting of the parser. For e.g., {@code
If the underlying reader throws an IOException during any operation, the CharacterReader will throw an @@ -583,7 +585,7 @@ boolean matches(String seq) { } /** - Checks if the current buffer position matches the sequence case-insensitively. + Checks if the current buffer position matches the sequence using ASCII case-insensitive matching. */ boolean matchesIgnoreCase(String seq) { bufferUp(); @@ -600,8 +602,8 @@ private boolean rangeMatchesIgnoreCase(String seq, int start) { char target = charBuf[start + offset]; if (scan == target) continue; - scan = Character.toUpperCase(scan); - target = Character.toUpperCase(target); + scan = asciiLowerCase(scan); + target = asciiLowerCase(target); if (scan != target) return false; } return true; diff --git a/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java b/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java index b3e204881b..23125691cd 100644 --- a/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java +++ b/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java @@ -301,7 +301,7 @@ static boolean isHtmlIntegration(Element el) { */ if (Parser.NamespaceMathml.equals(el.tag().namespace()) && el.nameIs("annotation-xml")) { - String encoding = Normalizer.normalize(el.attr("encoding")); + String encoding = Normalizer.asciiLowerCase(el.attr("encoding")); if (encoding.equals("text/html") || encoding.equals("application/xhtml+xml")) return true; } diff --git a/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java b/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java index c3cfe33c77..09b27bfdc0 100644 --- a/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java +++ b/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java @@ -14,6 +14,8 @@ import java.util.ArrayList; +import static org.jsoup.internal.Normalizer.asciiLowerCase; +import static org.jsoup.internal.Normalizer.equalsIgnoreAsciiCase; import static org.jsoup.internal.StringUtil.inSorted; import static org.jsoup.parser.HtmlTreeBuilder.isSpecial; import static org.jsoup.parser.HtmlTreeBuilderState.Constants.*; @@ -31,13 +33,13 @@ enum HtmlTreeBuilderState { } else if (t.isDoctype()) { // todo: parse error check on expected doctypes Token.Doctype d = t.asDoctype(); - DocumentType doctype = new DocumentType( - tb.settings.normalizeTag(d.getName()), d.getPublicIdentifier(), d.getSystemIdentifier()); + String name = tb.settings.preserveTagCase() ? d.getName() : asciiLowerCase(d.getName()); + DocumentType doctype = new DocumentType(name, d.getPublicIdentifier(), d.getSystemIdentifier()); doctype.setPubSysKey(d.getPubSysKey()); tb.getDocument().appendChild(doctype); tb.onNodeInserted(doctype); // todo: quirk state check on more doctype ids, if deemed useful (most are ancient legacy and presumably irrelevant) - if (d.isForceQuirks() || !doctype.name().equals("html") || doctype.publicId().equalsIgnoreCase("HTML")) + if (d.isForceQuirks() || !doctype.name().equals("html") || equalsIgnoreAsciiCase(doctype.publicId(), "HTML")) tb.getDocument().quirksMode(Document.QuirksMode.quirks); tb.transition(BeforeHtml); } else { @@ -422,7 +424,7 @@ private boolean inBodyStartTag(Token t, HtmlTreeBuilder tb) { case "input": tb.reconstructFormattingElements(); el = tb.insertEmptyElementFor(startTag); - if (!el.attr("type").equalsIgnoreCase("hidden")) + if (!equalsIgnoreAsciiCase(el.attr("type"), "hidden")) tb.framesetOk(false); break; case "hr": @@ -1024,7 +1026,7 @@ private boolean inBodyEndTagAdoption(Token t, HtmlTreeBuilder tb) { } else if (name.equals("noscript")) { tb.startNoscript(startTag); } else if (name.equals("input")) { - if (!(startTag.hasAttributes() && startTag.attributes.get("type").equalsIgnoreCase("hidden"))) { + if (!(startTag.hasAttributes() && equalsIgnoreAsciiCase(startTag.attributes.get("type"), "hidden"))) { return anythingElse(t, tb); } else { tb.insertEmptyElementFor(startTag); diff --git a/src/main/java/org/jsoup/parser/ParseSettings.java b/src/main/java/org/jsoup/parser/ParseSettings.java index b31e5f8cce..3d7a05b500 100644 --- a/src/main/java/org/jsoup/parser/ParseSettings.java +++ b/src/main/java/org/jsoup/parser/ParseSettings.java @@ -1,17 +1,18 @@ package org.jsoup.parser; +import org.jsoup.internal.StringUtil; import org.jsoup.nodes.Attributes; -import org.jspecify.annotations.Nullable; -import static org.jsoup.internal.Normalizer.lowerCase; -import static org.jsoup.internal.Normalizer.normalize; +import static org.jsoup.internal.Normalizer.asciiLowerCase; /** * Controls parser case settings, to optionally preserve tag and/or attribute name case. + * Case conversion uses ASCII rules. + * Programmatic name normalization also trims surrounding ASCII whitespace. */ public class ParseSettings { /** - * HTML default settings: both tag and attribute names are lower-cased during parsing. + * HTML defaults: lower-case tag and attribute names. */ public static final ParseSettings htmlDefault; /** @@ -56,22 +57,22 @@ public ParseSettings(boolean tag, boolean attribute) { } /** - * Normalizes a tag name according to the case preservation setting. + * Normalizes a tag name according to these settings. */ public String normalizeTag(String name) { - name = name.trim(); + name = StringUtil.trimAsciiWhitespace(name); if (!preserveTagCase) - name = lowerCase(name); + name = asciiLowerCase(name); return name; } /** - * Normalizes an attribute according to the case preservation setting. + * Normalizes an attribute name according to these settings. */ public String normalizeAttribute(String name) { - name = name.trim(); + name = StringUtil.trimAsciiWhitespace(name); if (!preserveAttributeCase) - name = lowerCase(name); + name = asciiLowerCase(name); return name; } @@ -80,9 +81,4 @@ void normalizeAttributes(Attributes attributes) { attributes.normalize(); } } - - /** Returns the normal name that a Tag will have (trimmed and lower-cased) */ - static String normalName(String name) { - return normalize(name); - } } diff --git a/src/main/java/org/jsoup/parser/Tag.java b/src/main/java/org/jsoup/parser/Tag.java index aac1da4ed8..76a7ee7c9e 100644 --- a/src/main/java/org/jsoup/parser/Tag.java +++ b/src/main/java/org/jsoup/parser/Tag.java @@ -6,11 +6,13 @@ import java.util.Objects; +import static org.jsoup.internal.Normalizer.asciiLowerCase; import static org.jsoup.parser.Parser.NamespaceHtml; /** A Tag represents an Element's name and configured options, common throughout the Document. Options may affect the parse and output. +
A tag's normalized name uses ASCII lowercase; other characters are unchanged.
@see TagSet @see Parser#tagSet(TagSet) */ @@ -54,7 +56,7 @@ public class Tag implements Cloneable { @since 1.20.1 */ public Tag(String tagName, String namespace) { - this(tagName, ParseSettings.normalName(tagName), namespace); + this(tagName, asciiLowerCase(tagName), namespace); } /** @@ -65,7 +67,7 @@ public Tag(String tagName, String namespace) { @since 1.20.1 */ public Tag(String tagName) { - this(tagName, ParseSettings.normalName(tagName), NamespaceHtml); + this(tagName, asciiLowerCase(tagName), NamespaceHtml); } /** Path for TagSet defaults, no options set; normal name is already LC. */ @@ -103,7 +105,7 @@ public String name() { public Tag name(String tagName) { if (is(RcData) || is(Data)) validateTextTagName(tagName); this.tagName = tagName; - this.normalName = ParseSettings.normalName(tagName); + this.normalName = asciiLowerCase(tagName); setParserOptions(); return this; } @@ -133,7 +135,7 @@ public String localName() { } /** - * Get this tag's normalized (lowercased) name. + * Get this tag's normalized name. * @return the tag's normal name. */ public String normalName() { diff --git a/src/main/java/org/jsoup/parser/TagSet.java b/src/main/java/org/jsoup/parser/TagSet.java index a55ef55d0a..51d840d533 100644 --- a/src/main/java/org/jsoup/parser/TagSet.java +++ b/src/main/java/org/jsoup/parser/TagSet.java @@ -1,6 +1,7 @@ package org.jsoup.parser; import org.jsoup.helper.Validate; +import org.jsoup.internal.StringUtil; import org.jsoup.internal.SharedConstants; import org.jspecify.annotations.Nullable; @@ -10,6 +11,7 @@ import java.util.Objects; import java.util.function.Consumer; +import static org.jsoup.internal.Normalizer.asciiLowerCase; import static org.jsoup.parser.Parser.NamespaceHtml; import static org.jsoup.parser.Parser.NamespaceMathml; import static org.jsoup.parser.Parser.NamespaceSvg; @@ -137,13 +139,13 @@ private void doAdd(Tag tag) { Tag valueOf(String tagName, @Nullable String normalName, String namespace, boolean preserveTagCase) { Validate.notNull(tagName); Validate.notNull(namespace); - if (normalName == null) tagName = tagName.trim(); // public API input; tokenizer names are already delimited + if (normalName == null) tagName = StringUtil.trimAsciiWhitespace(tagName); // public API input; tokenizer names are already delimited Validate.notEmpty(tagName); Tag tag = get(tagName, namespace); if (tag != null) return tag; // not found by tagName, try by normal - if (normalName == null) normalName = ParseSettings.normalName(tagName); + if (normalName == null) normalName = asciiLowerCase(tagName); tagName = preserveTagCase ? tagName : normalName; tag = get(normalName, namespace); if (tag != null) { diff --git a/src/main/java/org/jsoup/parser/Token.java b/src/main/java/org/jsoup/parser/Token.java index 44e58638fd..4d197d816b 100644 --- a/src/main/java/org/jsoup/parser/Token.java +++ b/src/main/java/org/jsoup/parser/Token.java @@ -9,7 +9,7 @@ import java.util.Arrays; import java.util.Objects; -import static org.jsoup.internal.Normalizer.lowerCase; +import static org.jsoup.internal.Normalizer.asciiLowerCase; /** * Parse tokens for the Tokeniser. @@ -171,9 +171,7 @@ final void newAttribute() { attributes = new Attributes(); if (attrName.hasData() && attributes.size() < MaxAttributes) { - // the tokeniser has skipped whitespace control chars, but trimming could collapse to empty for other control codes, so verify here String name = attrName.value(); - name = name.trim(); if (!name.isEmpty()) { String value; if (attrValue.hasData()) @@ -243,7 +241,7 @@ final void finaliseAttributeRanges(ParseSettings settings) { attrRangeCount = 0; for (int i = 0; i < count; i++) { String stagedName = Objects.requireNonNull(attrRangeNames[i]); - String rangeName = settings.normalizeAttribute(stagedName); + String rangeName = settings.preserveAttributeCase() ? stagedName : asciiLowerCase(stagedName); Range.AttributeRange existing = attributes.sourceRange(rangeName); if (!existing.isTracked()) { int rangeIndex = attrRangeIndex(i); @@ -301,7 +299,7 @@ final String toStringName() { final Tag name(String name) { tagName.set(name); - normalName = lowerCase(tagName.value()); + normalName = asciiLowerCase(tagName.value()); return this; } @@ -314,7 +312,7 @@ final void appendTagName(String append) { // might have null chars - need to replace with null replacement character append = append.replace(TokeniserState.nullChar, Tokeniser.replacementChar); tagName.append(append); - normalName = lowerCase(tagName.value()); + normalName = asciiLowerCase(tagName.value()); } final void appendTagName(char append) { @@ -389,7 +387,7 @@ Tag reset() { StartTag nameAttr(String name, Attributes attributes) { this.tagName.set(name); this.attributes = attributes; - normalName = lowerCase(name); + normalName = asciiLowerCase(name); return this; } diff --git a/src/main/java/org/jsoup/parser/Tokeniser.java b/src/main/java/org/jsoup/parser/Tokeniser.java index 91605a8903..4765429f1e 100644 --- a/src/main/java/org/jsoup/parser/Tokeniser.java +++ b/src/main/java/org/jsoup/parser/Tokeniser.java @@ -8,6 +8,8 @@ import java.util.Arrays; +import static org.jsoup.internal.Normalizer.asciiLowerCase; + /** * Readers the input stream into tokens. */ @@ -88,7 +90,7 @@ void emit(Token token) { if (token.type == Token.TokenType.StartTag) { Token.StartTag startTag = (Token.StartTag) token; - lastStartTag = startTag.name(); + lastStartTag = startTag.normalName(); } else if (token.type == Token.TokenType.EndTag) { Token.EndTag endTag = (Token.EndTag) token; if (endTag.hasAttributes()) @@ -268,7 +270,7 @@ boolean isCdataAllowed() { /** Test if the pending end tag matches the last emitted start tag. */ boolean isAppropriateEndTagToken() { - return lastStartTag != null && tagPending.name().equalsIgnoreCase(lastStartTag); + return lastStartTag != null && tagPending.normalName().equals(lastStartTag); } /** Test if appending the character would keep the pending end tag a prefix of the expected name. */ @@ -278,8 +280,8 @@ boolean isAppropriateEndTagPrefix(char next) { String candidate = tagPending.normalName(); int length = candidate.length(); return length < lastStartTag.length() - && lastStartTag.regionMatches(true, 0, candidate, 0, length) - && lastStartTag.charAt(length) == next; + && lastStartTag.startsWith(candidate) + && lastStartTag.charAt(length) == asciiLowerCase(next); } void error(TokeniserState state) { diff --git a/src/main/java/org/jsoup/parser/XmlTreeBuilder.java b/src/main/java/org/jsoup/parser/XmlTreeBuilder.java index 7b92c9e581..1f834b23be 100644 --- a/src/main/java/org/jsoup/parser/XmlTreeBuilder.java +++ b/src/main/java/org/jsoup/parser/XmlTreeBuilder.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.Map; +import static org.jsoup.internal.Normalizer.asciiLowerCase; import static org.jsoup.parser.Parser.NamespaceXml; /** @@ -223,7 +224,7 @@ else if (currentElOrDoc().tag().is(Tag.Data)) } void insertDoctypeFor(Token.Doctype token) { - DocumentType doctypeNode = new DocumentType(settings.normalizeTag(token.getName()), token.getPublicIdentifier(), token.getSystemIdentifier()); + DocumentType doctypeNode = new DocumentType(settings.preserveTagCase() ? token.getName() : asciiLowerCase(token.getName()), token.getPublicIdentifier(), token.getSystemIdentifier()); doctypeNode.setPubSysKey(token.getPubSysKey()); if (token.hasInternalSubset()) doctypeNode.setInternalSubset(token.getInternalSubset()); @@ -243,7 +244,7 @@ void insertXmlDeclarationFor(Token.XmlDecl token) { * @param endTag tag to close */ protected void popStackToClose(Token.EndTag endTag) { - String elName = settings.normalizeTag(endTag.name()); + String elName = settings.preserveTagCase() ? endTag.name() : asciiLowerCase(endTag.name()); Element firstFound = null; for (int pos = stack.size() -1; pos >= 0; pos--) { diff --git a/src/main/java/org/jsoup/safety/Safelist.java b/src/main/java/org/jsoup/safety/Safelist.java index 13fb5abcde..b9387a8512 100644 --- a/src/main/java/org/jsoup/safety/Safelist.java +++ b/src/main/java/org/jsoup/safety/Safelist.java @@ -256,7 +256,7 @@ public Safelist addTags(String... tags) { for (String tagName : tags) { Validate.notEmpty(tagName); - Validate.isFalse(tagName.equalsIgnoreCase("noscript"), + Validate.isFalse(Normalizer.equalsIgnoreAsciiCase(tagName, "noscript"), "noscript is unsupported in Safelists, due to incompatibilities between parsers with and without script-mode enabled"); tagNames.add(TagName.valueOf(tagName)); } @@ -632,7 +632,7 @@ static class TagName extends TypedValue { } static TagName valueOf(String value) { - return new TagName(Normalizer.lowerCase(value)); + return new TagName(Normalizer.asciiLowerCase(value)); } } @@ -642,7 +642,7 @@ static class AttributeKey extends TypedValue { } static AttributeKey valueOf(String value) { - return new AttributeKey(Normalizer.lowerCase(value)); + return new AttributeKey(Normalizer.asciiLowerCase(value)); } } diff --git a/src/main/java/org/jsoup/select/Evaluator.java b/src/main/java/org/jsoup/select/Evaluator.java index f3b6a3534f..2335817338 100644 --- a/src/main/java/org/jsoup/select/Evaluator.java +++ b/src/main/java/org/jsoup/select/Evaluator.java @@ -17,7 +17,8 @@ import java.util.regex.Pattern; import static org.jsoup.internal.Normalizer.lowerCase; -import static org.jsoup.internal.Normalizer.normalize; +import static org.jsoup.internal.Normalizer.asciiLowerCase; +import static org.jsoup.internal.StringUtil.trimAsciiWhitespace; import static org.jsoup.internal.StringUtil.normaliseWhitespace; @@ -237,14 +238,14 @@ public static final class AttributeStarting extends Evaluator { public AttributeStarting(String keyPrefix) { Validate.notNull(keyPrefix); // OK to be empty - will find elements with any attributes - this.keyPrefix = lowerCase(keyPrefix); + this.keyPrefix = asciiLowerCase(keyPrefix); } @Override public boolean matches(Element root, Element element) { List- A selector is a chain of simple selectors, separated by combinators. Selectors are case-insensitive (including - against elements, attributes, and attribute values). -
+A selector is a chain of simple selectors, separated by combinators.
+Tag and attribute names are matched ASCII case-insensitively, so non-ASCII case variants remain distinct. + Attribute-value and case-insensitive text searches use Unicode case rules.
The universal selector {@code *} is implicit when no element selector is supplied (i.e. {@code .header} and {@code *.header} are equivalent). diff --git a/src/test/java/org/jsoup/internal/NormalizerTest.java b/src/test/java/org/jsoup/internal/NormalizerTest.java new file mode 100644 index 0000000000..fa927706b7 --- /dev/null +++ b/src/test/java/org/jsoup/internal/NormalizerTest.java @@ -0,0 +1,34 @@ +package org.jsoup.internal; + +import org.jsoup.MultiLocaleExtension.MultiLocaleTest; +import org.junit.jupiter.api.Test; + +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.*; + +class NormalizerTest { + @MultiLocaleTest void separatesAsciiAndUnicodeCase(Locale locale) { + Locale.setDefault(locale); + String input = "AZ ÄKİıſ😀\u0001 "; + assertEquals("az ÄKİıſ😀\u0001 ", Normalizer.asciiLowerCase(input)); + assertEquals("az äki\u0307ıſ😀\u0001 ", Normalizer.lowerCase(input)); + assertEquals("", Normalizer.asciiLowerCase(null)); + assertEquals("", Normalizer.lowerCase(null)); + assertTrue(Normalizer.equalsIgnoreAsciiCase("TeXT/HTML", "text/html")); + for (String[] pair : new String[][]{{"K", "k"}, {"İ", "i"}, {"ı", "i"}, {"ſ", "s"}, {"Ä", "ä"}}) + assertFalse(Normalizer.equalsIgnoreAsciiCase(pair[0], pair[1])); + assertTrue(Normalizer.equalsIgnoreAsciiCase("Ä😀", "Ä😀")); + assertFalse(Normalizer.equalsIgnoreAsciiCase("Ä", "ä")); + assertFalse(Normalizer.equalsIgnoreAsciiCase("ſ", "s")); + assertFalse(Normalizer.equalsIgnoreAsciiCase("x", null)); + assertFalse(Normalizer.equalsIgnoreAsciiCase("x", " x")); + } + + @Test void trimsOnlyAsciiWhitespace() { + String name = "\u0000\u0001\u000b\u00a0name\u0001"; + assertEquals(name, StringUtil.trimAsciiWhitespace(" \t\n\f\r" + name + "\r\f\n\t ")); + assertEquals("", StringUtil.trimAsciiWhitespace(" \t\n\f\r")); + assertEquals("", StringUtil.trimAsciiWhitespace("")); + } +} diff --git a/src/test/java/org/jsoup/nodes/AttributeTest.java b/src/test/java/org/jsoup/nodes/AttributeTest.java index 4cff320532..399236da7e 100644 --- a/src/test/java/org/jsoup/nodes/AttributeTest.java +++ b/src/test/java/org/jsoup/nodes/AttributeTest.java @@ -116,4 +116,13 @@ public void htmlWithLtAndGtInValue() { Attribute attr = new Attribute("one", "two"); assertEquals("", attr.namespace()); } + + @Test void trimsWhitespaceButPreservesNameControls() { + Attribute attr = new Attribute(" \tDATA-X\u0001\n", "one"); + assertEquals("DATA-X\u0001", attr.getKey()); + attr.setKey(" \r\u0001\f"); + assertEquals("\u0001", attr.getKey()); + assertEquals("_=\"one\"", attr.html()); // serialization still repairs invalid HTML names + } + } diff --git a/src/test/java/org/jsoup/nodes/AttributesTest.java b/src/test/java/org/jsoup/nodes/AttributesTest.java index 6e69447001..e9ea883504 100644 --- a/src/test/java/org/jsoup/nodes/AttributesTest.java +++ b/src/test/java/org/jsoup/nodes/AttributesTest.java @@ -473,4 +473,14 @@ public void testBoolean() { assertTrue(attrs.isEmpty()); } + + @Test void repairedHtmlKeysUseAsciiCollisionChecks() { + Attributes attrs = new Attributes(); + attrs.add("K_", "unicode"); + attrs.add("k\u0001", "repaired"); + assertEquals(" K_=\"unicode\" k_=\"repaired\"", attrs.html()); + attrs.add("K_", "original"); + assertEquals(" K_=\"unicode\" _k_=\"repaired\" K_=\"original\"", attrs.html()); + } + } diff --git a/src/test/java/org/jsoup/parser/CharacterReaderTest.java b/src/test/java/org/jsoup/parser/CharacterReaderTest.java index 17c9dde00d..3c7e549e0b 100644 --- a/src/test/java/org/jsoup/parser/CharacterReaderTest.java +++ b/src/test/java/org/jsoup/parser/CharacterReaderTest.java @@ -503,4 +503,18 @@ public void notEmptyAtBufferSplitPoint() { assertEquals('&', r.consume()); } + + @Test void keywordMatchingUsesAsciiCaseAcrossRefills() { + for (int offset : new int[]{0, CharacterReader.BufferSize - 3, CharacterReader.BufferSize + 3}) { + String padding = StringUtil.padding(offset, offset); + try (CharacterReader reader = new CharacterReader(padding + "PUBLİC PUBLIC")) { + for (int i = 0; i < offset; i++) reader.consume(); + assertFalse(reader.matchesIgnoreCase("public")); + assertTrue(reader.matchConsumeIgnoreCase("publİc")); + reader.consume(); + assertTrue(reader.matchConsumeIgnoreCase("public")); + } + } + } + } diff --git a/src/test/java/org/jsoup/parser/HtmlParserTest.java b/src/test/java/org/jsoup/parser/HtmlParserTest.java index 2addc411df..dbc69fc155 100644 --- a/src/test/java/org/jsoup/parser/HtmlParserTest.java +++ b/src/test/java/org/jsoup/parser/HtmlParserTest.java @@ -1542,7 +1542,12 @@ public void testInvalidTableContents() throws IOException { @Test public void handlesControlCodeInAttributeName() { Document doc = Jsoup.parse("
"); - assertEquals("", doc.body().html()); + assertEquals("foo", doc.select("a").get(0).attr("\06")); + assertEquals("bar", doc.select("a").get(1).attr("\06")); + assertEquals("bar", doc.select("a").get(2).attr("foo\06")); + assertFalse(doc.select("a").get(2).hasAttr("foo")); + // invalid names are repaired for serialization, but are not trimmed or dropped while parsing + assertEquals("", doc.body().html()); } @Test public void caseSensitiveParseTree() { diff --git a/src/test/java/org/jsoup/parser/NameNormalizationTest.java b/src/test/java/org/jsoup/parser/NameNormalizationTest.java new file mode 100644 index 0000000000..1a53959f40 --- /dev/null +++ b/src/test/java/org/jsoup/parser/NameNormalizationTest.java @@ -0,0 +1,121 @@ +package org.jsoup.parser; + +import org.jsoup.Jsoup; +import org.jsoup.internal.StringUtil; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.nodes.Range; +import org.junit.jupiter.api.Test; + +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.*; + +class NameNormalizationTest { + @Test void integrationEncodingMatchesWithoutTrimmingOrUnicodeFolding() { + for (String encoding : new String[]{"text/html", "application/xhtml+xml"}) { + for (String value : new String[]{encoding, encoding.toUpperCase(Locale.ROOT)}) { + Document doc = Jsoup.parse("