Repository: jhy/jsoup
Pull request #1552 (2021-05-21 11:54:10)

Fix issue #1425
Fix #issue1425


Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..e3656835
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,7 @@
+version: 2
+
+updates:
+  - package-ecosystem: maven
+    directory: /
+    schedule:
+      interval: weekly
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f4f06da6..1dd5333c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,5 +1,9 @@
 name: Build
-on: [push, pull_request]
+on:
+  push:
+    branches:
+      - master
+  pull_request:
 
 jobs:
   test:
@@ -8,7 +12,7 @@ jobs:
       matrix:
         os: [ubuntu-latest, windows-latest, macOS-latest]
         # choosing to run a reduced set of LTS, current, and next, to balance coverage and execution time
-        java: [8, 11, 15, 16-ea]
+        java: [8, 11, 15, 16]
       fail-fast: false
     name: Test JDK ${{ matrix.java }}, ${{ matrix.os }}
     steps:
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 00000000..f387b2f7
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,37 @@
+name: CodeQL
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+  schedule:
+    - cron: '0 5 * * 3'
+
+jobs:
+  codeql:
+    runs-on: ubuntu-latest
+    name: "CodeQL"
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+      - name: Set up JDK 11
+        uses: actions/setup-java@v1
+        with:
+          java-version: 11
+      - name: Cache local Maven repository
+        uses: actions/cache@v2
+        with:
+          path: ~/.m2/repository
+          key: ${{ runner.os }}-maven-java-codeql-${{ hashFiles('**/pom.xml') }}
+          restore-keys: |
+            ${{ runner.os }}-maven-
+      - name: CodeQL Initialization
+        uses: github/codeql-action/init@v1
+        with:
+          languages: java
+          queries: +security-and-quality
+      - name: Autobuild
+        uses: github/codeql-action/autobuild@v1
+      - name: CodeQL Analysis
+        uses: github/codeql-action/analyze@v1
diff --git a/CHANGES b/CHANGES
index 6616cea1..262503ea 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,6 +1,39 @@
 jsoup changelog
 
-*** Release 1.14.1 [PENDING]
+*** Release 1.14.2 [PENDING]
+  * Improvement: support Pattern.quote \Q and \E escapes in the selector regex matchers.
+    <https://github.com/jhy/jsoup/pull/1536>
+
+  * Bugfix: the *|el wildcard namespace selector now also matches elements with no namespace.
+    <https://github.com/jhy/jsoup/issues/1565>
+    
+  * Bugfix: corrected a potential case of the parser input stream not being closed immediately on a read exception.
+
+  * Bugfix: when making a HTTP POST, if the request write fails, make sure the connection is immediately cleaned up.
+
+  * Bugfix: in the XML parser, XML processing instructions without attributes would be serialized as if they did.
+    <https://github.com/jhy/jsoup/issues/770>
+
+  * Bugfix: updated the HtmlTreeParser resetInsertionMode to the current spec for supported elements
+    <https://github.com/jhy/jsoup/issues/1491>
+
+  * Bugfix [Fuzz]: fixed a slow parse when a tag or an attribute name has thousands of null characters in it.
+    <https://github.com/jhy/jsoup/issues/1580>
+
+  * Bugfix [Fuzz]: the adoption agency algorithm can have an incorrect bookmark position
+    <https://github.com/jhy/jsoup/issues/1576>
+
+  * Bugfiz [Fuzz]: malformed HTML could result in null elements on stack
+    <https://github.com/jhy/jsoup/issues/1579>
+
+  * Bugfix [Fuzz]: malformed deeply nested table elements could create a stack overflow.
+    <https://github.com/jhy/jsoup/issues/1577>
+
+  * Bugfix [Fuzz]: Speed optimized malformed HTML creating elements with thousands of elements - limit the attribute
+    count per element when parsing to 512 (in real-world HTML, P99 is ~ 8).
+    <https://github.com/jhy/jsoup/issues/1578>
+
+*** Release 1.14.1 [2021-Jul-10]
   * Change: updated the minimum supported Java version from Java 7 to Java 8.
 
   * Change: updated the minimum Android API level from 8 to 10.
@@ -13,6 +46,26 @@ jsoup changelog
     you may need to make a code update.
     <https://github.com/jhy/jsoup/issues/1431>
 
+  * Change: the org.jsoup.Connection interface has been modified to introduce new methods for sessions and the cookie
+    store. If you have a custom implementation of this interface, you will need to add implementations of these methods.
+
+  * Improvement: added HTTP request session management support with Jsoup.newSession(). This extends the Connection
+    implementation to support (optional) sessions, which allow request defaults (timeout, proxy, etc) to be set once and
+    then applied to all requests within that session.
+
+    Cookies are re-implemented to correctly support path and domain filtering when used within a session. A default
+    in-memory cookie store is used for the session, or a custom implementation (perhaps disk-persistent, or pre-set)
+    can be used instead.
+
+    Forms submitted using the FormElement#submit() use the same session that was used to fetch the document and so pass
+    cookies and other defaults appropriately.
+
+    The session is multi-thread safe and can execute multiple requests concurrently. If the user accidentally tries to
+    execute the same request object across multiple threads (vs calling Connection#newRequest()),
+    that is detected cleanly and a clear exception is thrown (vs weird blowups in input stream reading, or forcing
+    everything through a synchronized bottleneck.
+    <https://github.com/jhy/jsoup/pull/1476>
+
   * Improvement: renamed the Whitelist class to Safelist, with the goal of more inclusive language. A shim is provided
     for backwards compatibility (source and binary). This shim is marked as deprecated and will be removed in the
     jsoup 1.15.1 release.
@@ -51,13 +104,27 @@ jsoup changelog
   * Improvement: added Element#insertChildren and Elment#prependChildren, as convenience methods in addition to
     Element#insertChildren(index, children), for bulk moving nodes.
 
+  * Improvement: clean up relative URLs with too many .. segments better.
+    <https://github.com/jhy/jsoup/pull/1482>
+
+  * Build Improvement: integrated jsoup into the OSS Fuzz project, which semi-randomly generates millions of different
+    HTML and XML input files, searching for areas to improve in the parser for increased robustness and throughput.
+    <https://github.com/jhy/jsoup/issues/1502>
+
+  * Build Improvement: integrated with GitHub's CodeQL static code analyzer.
+    <https://github.com/jhy/jsoup/pull/1494>
+
   * Build Improvement: moved to GitHub Workflows for build verification.
 
-  * Build Improvement: updated Jetty (used for integration tests; not bundled) to 9.4.35.v2020112.
+  * Build Improvement: updated Jetty (used for integration tests; not bundled) to 9.4.42.
 
   * Build Improvement: added nullability annotations and initial settings.
     <https://github.com/jhy/jsoup/pull/1467>
 
+  * Bugfix: corrected the adoption agency algorithm, to handle cases where e.g. a <a> tag incorrectly nests further <a>
+    tags.
+    <https://github.com/jhy/jsoup/pull/1517> <https://github.com/jhy/jsoup/issues/845>
+
   * Bugfix: when parsing HTML, could throw NPEs on some tags (isindex or table>input).
     <https://github.com/jhy/jsoup/issues/1404>
 
@@ -97,6 +164,33 @@ jsoup changelog
 
   * Bugfix: if a HTML file ended with an open noscript tag, an "EOF" string would appear in the HTML output.
 
+  * Bugfix: when parsing a document as XML, automatically set the output syntax to XML, and ensure that "<" characters
+    in attributes are escaped as "&lt" (which is not required in HTML as the quoted attribute contents are safe, but is
+    required in XML).
+    <https://github.com/jhy/jsoup/issues/1420>
+
+  * Bugfix: [Fuzz] when parsing an attribute key containing "abs:abs", a validation error would be incorrectly
+    thrown.
+    <https://github.com/jhy/jsoup/issues/1541>
+
+  * Bugfix: [Fuzz] could NPE while parsing in resetInsertionMode().
+    <https://github.com/jhy/jsoup/issues/1538>
+
+  * Bugfix: [Fuzz] when parsing XML, could Stack Overflow when parsing XML declarations.
+    <https://github.com/jhy/jsoup/issues/1539>
+
+  * Bugfix: [Fuzz] fixed a potential Stack Overflow when parsing mis-nested tfoot tags, and updated the tree parser for
+    this situation to match the updated HTML5 spec.
+    <https://github.com/jhy/jsoup/issues/1543>
+
+  * Bugfix: [Fuzz] fixed a potentially slow HTML parse when tags are nested extremely deep (e.g. 88K depth), by limiting
+    the formatting tag search depth to 256. In practice, it's generally between 4 - 8.
+    <https://github.com/jhy/jsoup/issues/1544>
+
+  * Bugfix: [Fuzz] when parsing an unterminated RCDATA token (e.g. a <title> tag), could throw an IO Exception "No
+    buffer left to unconsume" when trying to rewind the buffer.
+    <https://github.com/jhy/jsoup/issues/1542>
+
 *** Release 1.13.1 [2020-Feb-29]
   * Improvement: added Element#closest(selector), which walks up the tree to find the nearest element matching the
     selector.
diff --git a/pom.xml b/pom.xml
index bc1cfed9..a3f9fd29 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
 
   <groupId>org.jsoup</groupId>
   <artifactId>jsoup</artifactId>
-  <version>1.14.1-SNAPSHOT</version>
+  <version>1.14.2-SNAPSHOT</version>
   <description>jsoup is a Java library for working with real-world HTML. It provides a very convenient API for fetching URLs and extracting and manipulating data, using the best of HTML5 DOM methods and CSS selectors. jsoup implements the WHATWG HTML5 specification, and parses HTML to the same DOM as modern browsers do.</description>
   <url>https://jsoup.org/</url>
   <inceptionYear>2009</inceptionYear>
@@ -40,7 +40,7 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-compiler-plugin</artifactId>
-        <version>3.8.0</version>
+        <version>3.8.1</version>
         <configuration>
           <source>1.8</source>
           <target>1.8</target>
@@ -83,7 +83,7 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-javadoc-plugin</artifactId>
-        <version>3.1.0</version>
+        <version>3.3.0</version>
         <configuration>
             <doclint>none</doclint>
             <source>7</source>
@@ -120,7 +120,7 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-jar-plugin</artifactId>
-        <version>3.0.2</version>
+        <version>3.2.0</version>
         <configuration>
           <archive>
             <manifestEntries>
@@ -136,7 +136,7 @@
       <plugin>
         <groupId>org.apache.felix</groupId>
         <artifactId>maven-bundle-plugin</artifactId>
-        <version>2.5.4</version>
+        <version>5.1.2</version>
         <executions>
           <execution>
             <id>bundle-manifest</id>
@@ -156,7 +156,7 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-resources-plugin</artifactId>
-        <version>3.0.1</version>
+        <version>3.2.0</version>
       </plugin>
       <plugin>
         <artifactId>maven-release-plugin</artifactId>
@@ -165,11 +165,15 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-surefire-plugin</artifactId>
-        <version>3.0.0-M3</version>
+        <version>3.0.0-M5</version>
+        <configuration>
+          <!-- smaller stack to find stack overflows -->
+          <argLine>-Xss256k</argLine>
+        </configuration>
       </plugin>
       <plugin>
         <artifactId>maven-failsafe-plugin</artifactId>
-        <version>3.0.0-M3</version>
+        <version>3.0.0-M5</version>
         <executions>
           <execution>
             <goals>
@@ -283,7 +287,7 @@
         <plugins>
           <plugin>
             <artifactId>maven-failsafe-plugin</artifactId>
-            <version>2.22.0</version>
+            <version>3.0.0-M5</version>
             <executions>
               <execution>
                 <goals>
@@ -304,7 +308,7 @@
     <dependency>
       <groupId>org.junit.jupiter</groupId>
       <artifactId>junit-jupiter</artifactId>
-      <version>5.6.0</version>
+      <version>5.7.2</version>
       <scope>test</scope>
     </dependency>
 
@@ -312,15 +316,15 @@
       <!-- gson, to fetch entities from w3.org -->
       <groupId>com.google.code.gson</groupId>
       <artifactId>gson</artifactId>
-      <version>2.7</version>
+      <version>2.8.7</version>
       <scope>test</scope>
     </dependency>
 
     <dependency>
-      <!-- jetty for webserver integration tests. 9.2 is last with Java7 support -->
+      <!-- jetty for webserver integration tests. 9.x is last with Java7 support -->
       <groupId>org.eclipse.jetty</groupId>
       <artifactId>jetty-server</artifactId>
-      <version>9.4.35.v20201120</version>
+      <version>9.4.42.v20210604</version>
       <scope>test</scope>
     </dependency>
 
@@ -328,7 +332,7 @@
       <!-- jetty for webserver integration tests -->
       <groupId>org.eclipse.jetty</groupId>
       <artifactId>jetty-servlet</artifactId>
-      <version>9.4.35.v20201120</version>
+      <version>9.4.42.v20210604</version>
       <scope>test</scope>
     </dependency>
 
diff --git a/src/main/java/org/jsoup/helper/DataUtil.java b/src/main/java/org/jsoup/helper/DataUtil.java
index 406ea5bd..a111a4e6 100644
--- a/src/main/java/org/jsoup/helper/DataUtil.java
+++ b/src/main/java/org/jsoup/helper/DataUtil.java
@@ -65,8 +65,13 @@ public final class DataUtil {
         String name = Normalizer.lowerCase(in.getName());
         if (name.endsWith(".gz") || name.endsWith(".z")) {
             // unfortunately file input streams don't support marks (why not?), so we will close and reopen after read
-            boolean zipped = (stream.read() == 0x1f && stream.read() == 0x8b); // gzip magic bytes
-            stream.close();
+            boolean zipped;
+            try {
+                zipped = (stream.read() == 0x1f && stream.read() == 0x8b); // gzip magic bytes
+            } finally {
+                stream.close();
+
+            }
             stream = zipped ? new GZIPInputStream(new FileInputStream(in)) : new FileInputStream(in);
         }
         return parseInputStream(stream, charsetName, baseUri, Parser.htmlParser());
@@ -74,7 +79,7 @@ public final class DataUtil {
 
     /**
      * Parses a Document from an input steam.
-     * @param in input stream to parse. You will need to close it.
+     * @param in input stream to parse. The stream will be closed after reading.
      * @param charsetName character set of input
      * @param baseUri base URI of document, to resolve relative links against
      * @return Document
@@ -86,7 +91,7 @@ public final class DataUtil {
 
     /**
      * Parses a Document from an input steam, using the provided Parser.
-     * @param in input stream to parse. You will need to close it.
+     * @param in input stream to parse. The stream will be closed after reading.
      * @param charsetName character set of input
      * @param baseUri base URI of document, to resolve relative links against
      * @param parser alternate {@link Parser#xmlParser() parser} to use.
@@ -119,88 +124,97 @@ public final class DataUtil {
         @Nullable Document doc = null;
 
         // read the start of the stream and look for a BOM or meta charset
-        input.mark(bufferSize);
-        ByteBuffer firstBytes = readToByteBuffer(input, firstReadBufferSize - 1); // -1 because we read one more to see if completed. First read is < buffer size, so can't be invalid.
-        boolean fullyRead = (input.read() == -1);
-        input.reset();
+        try {
+            input.mark(bufferSize);
+            ByteBuffer firstBytes = readToByteBuffer(input, firstReadBufferSize - 1); // -1 because we read one more to see if completed. First read is < buffer size, so can't be invalid.
+            boolean fullyRead = (input.read() == -1);
+            input.reset();
 
-        // look for BOM - overrides any other header or input
-        BomCharset bomCharset = detectCharsetFromBom(firstBytes);
-        if (bomCharset != null)
-            charsetName = bomCharset.charset;
+            // look for BOM - overrides any other header or input
+            BomCharset bomCharset = detectCharsetFromBom(firstBytes);
+            if (bomCharset != null)
+                charsetName = bomCharset.charset;
 
-        if (charsetName == null) { // determine from meta. safe first parse as UTF-8
-            try {
-                CharBuffer defaultDecoded = UTF_8.decode(firstBytes);
-                if (defaultDecoded.hasArray())
-                    doc = parser.parseInput(new CharArrayReader(defaultDecoded.array(), defaultDecoded.arrayOffset(), defaultDecoded.limit()), baseUri);
-                else
-                    doc = parser.parseInput(defaultDecoded.toString(), baseUri);
-            } catch (UncheckedIOException e) {
-                throw e.ioException();
-            }
+            if (charsetName == null) { // determine from meta. safe first parse as UTF-8
+                try {
+                    CharBuffer defaultDecoded = UTF_8.decode(firstBytes);
+                    if (defaultDecoded.hasArray())
+                        doc = parser.parseInput(new CharArrayReader(defaultDecoded.array(), defaultDecoded.arrayOffset(), defaultDecoded.limit()), baseUri);
+                    else
+                        doc = parser.parseInput(defaultDecoded.toString(), baseUri);
+                } catch (UncheckedIOException e) {
+                    throw e.ioException();
+                }
 
-            // look for <meta http-equiv="Content-Type" content="text/html;charset=gb2312"> or HTML5 <meta charset="gb2312">
-            Elements metaElements = doc.select("meta[http-equiv=content-type], meta[charset]");
-            String foundCharset = null; // if not found, will keep utf-8 as best attempt
-            for (Element meta : metaElements) {
-                if (meta.hasAttr("http-equiv"))
-                    foundCharset = getCharsetFromContentType(meta.attr("content"));
-                if (foundCharset == null && meta.hasAttr("charset"))
-                    foundCharset = meta.attr("charset");
-                if (foundCharset != null)
-                    break;
-            }
+                // look for <meta http-equiv="Content-Type" content="text/html;charset=gb2312"> or HTML5 <meta charset="gb2312">
+                Elements metaElements = doc.select("meta[http-equiv=content-type], meta[charset]");
+                String foundCharset = null; // if not found, will keep utf-8 as best attempt
+                for (Element meta : metaElements) {
+                    if (meta.hasAttr("http-equiv"))
+                        foundCharset = getCharsetFromContentType(meta.attr("content"));
+                    if (foundCharset == null && meta.hasAttr("charset"))
+                        foundCharset = meta.attr("charset");
+                    if (foundCharset != null)
+                        break;
+                }
 
-            // look for <?xml encoding='ISO-8859-1'?>
-            if (foundCharset == null && doc.childNodeSize() > 0) {
-                Node first = doc.childNode(0);
-                XmlDeclaration decl = null;
-                if (first instanceof XmlDeclaration)
-                    decl = (XmlDeclaration) first;
-                else if (first instanceof Comment) {
-                    Comment comment = (Comment) first;
-                    if (comment.isXmlDeclaration())
-                        decl = comment.asXmlDeclaration();
+                // look for <?xml encoding='ISO-8859-1'?>
+                if (foundCharset == null && doc.childNodeSize() > 0) {
+                    Node first = doc.childNode(0);
+                    XmlDeclaration decl = null;
+                    if (first instanceof XmlDeclaration)
+                        decl = (XmlDeclaration) first;
+                    else if (first instanceof Comment) {
+                        Comment comment = (Comment) first;
+                        if (comment.isXmlDeclaration())
+                            decl = comment.asXmlDeclaration();
+                    }
+                    if (decl != null) {
+                        if (decl.name().equalsIgnoreCase("xml"))
+                            foundCharset = decl.attr("encoding");
+                    }
                 }
-                if (decl != null) {
-                    if (decl.name().equalsIgnoreCase("xml"))
-                        foundCharset = decl.attr("encoding");
+                foundCharset = validateCharset(foundCharset);
+                if (foundCharset != null && !foundCharset.equalsIgnoreCase(defaultCharsetName)) { // need to re-decode. (case insensitive check here to match how validate works)
+                    foundCharset = foundCharset.trim().replaceAll("[\"']", "");
+                    charsetName = foundCharset;
+                    doc = null;
+                } else if (!fullyRead) {
+                    doc = null;
                 }
+            } else { // specified by content type header (or by user on file load)
+                Validate.notEmpty(charsetName, "Must set charset arg to character set of file to parse. Set to null to attempt to detect from HTML");
             }
-            foundCharset = validateCharset(foundCharset);
-            if (foundCharset != null && !foundCharset.equalsIgnoreCase(defaultCharsetName)) { // need to re-decode. (case insensitive check here to match how validate works)
-                foundCharset = foundCharset.trim().replaceAll("[\"']", "");
-                charsetName = foundCharset;
-                doc = null;
-            } else if (!fullyRead) {
-                doc = null;
+            if (doc == null) {
+                if (charsetName == null)
+                    charsetName = defaultCharsetName;
+                BufferedReader reader = new BufferedReader(new InputStreamReader(input, charsetName), bufferSize); // Android level does not allow us try-with-resources
+                try {
+                    if (bomCharset != null && bomCharset.offset) { // creating the buffered reader ignores the input pos, so must skip here
+                        long skipped = reader.skip(1);
+                        Validate.isTrue(skipped == 1); // WTF if this fails.
+                    }
+                    try {
+                        doc = parser.parseInput(reader, baseUri);
+                    } catch (UncheckedIOException e) {
+                        // io exception when parsing (not seen before because reading the stream as we go)
+                        throw e.ioException();
+                    }
+                    Charset charset = charsetName.equals(defaultCharsetName) ? UTF_8 : Charset.forName(charsetName);
+                    doc.outputSettings().charset(charset);
+                    if (!charset.canEncode()) {
+                        // some charsets can read but not encode; switch to an encodable charset and update the meta el
+                        doc.charset(UTF_8);
+                    }
+                }
+                finally {
+                    reader.close();
+                }
             }
-        } else { // specified by content type header (or by user on file load)
-            Validate.notEmpty(charsetName, "Must set charset arg to character set of file to parse. Set to null to attempt to detect from HTML");
         }
-        if (doc == null) {
-            if (charsetName == null)
-                charsetName = defaultCharsetName;
-            BufferedReader reader = new BufferedReader(new InputStreamReader(input, charsetName), bufferSize);
-            if (bomCharset != null && bomCharset.offset) { // creating the buffered reader ignores the input pos, so must skip here
-                long skipped = reader.skip(1);
-                Validate.isTrue(skipped == 1); // WTF if this fails.
-            }
-            try {
-                doc = parser.parseInput(reader, baseUri);
-            } catch (UncheckedIOException e) {
-                // io exception when parsing (not seen before because reading the stream as we go)
-                throw e.ioException();
-            }
-            Charset charset = charsetName.equals(defaultCharsetName) ? UTF_8 : Charset.forName(charsetName);
-            doc.outputSettings().charset(charset);
-            if (!charset.canEncode()) {
-                // some charsets can read but not encode; switch to an encodable charset and update the meta el
-                doc.charset(UTF_8);
-            }
+        finally {
+            input.close();
         }
-        input.close();
         return doc;
     }
 
diff --git a/src/main/java/org/jsoup/helper/HttpConnection.java b/src/main/java/org/jsoup/helper/HttpConnection.java
index 7914803a..3ba82b4c 100644
--- a/src/main/java/org/jsoup/helper/HttpConnection.java
+++ b/src/main/java/org/jsoup/helper/HttpConnection.java
@@ -490,8 +490,10 @@ public class HttpConnection implements Connection {
         private static boolean looksLikeUtf8(byte[] input) {
             int i = 0;
             // BOM:
-            if (input.length >= 3 && (input[0] & 0xFF) == 0xEF
-                && (input[1] & 0xFF) == 0xBB & (input[2] & 0xFF) == 0xBF) {
+            if (input.length >= 3
+                && (input[0] & 0xFF) == 0xEF
+                && (input[1] & 0xFF) == 0xBB
+                && (input[2] & 0xFF) == 0xBF) {
                 i = 3;
             }
 
@@ -855,8 +857,12 @@ public class HttpConnection implements Connection {
             Response res = null;
             try {
                 conn.connect();
-                if (conn.getDoOutput())
-                    writePost(req, conn.getOutputStream(), mimeBoundary);
+                if (conn.getDoOutput()) {
+                    OutputStream out = conn.getOutputStream();
+                    try { writePost(req, out, mimeBoundary); }
+                    catch (IOException e) { conn.disconnect(); throw e; }
+                    finally { out.close(); }
+                }
 
                 int status = conn.getResponseCode();
                 res = new Response(conn, req, previousResponse);
diff --git a/src/main/java/org/jsoup/helper/W3CDom.java b/src/main/java/org/jsoup/helper/W3CDom.java
index 829c8fa9..d3dae0c5 100644
--- a/src/main/java/org/jsoup/helper/W3CDom.java
+++ b/src/main/java/org/jsoup/helper/W3CDom.java
@@ -283,7 +283,7 @@ public class W3CDom {
             }
 
             // get the element prefix if any
-            int pos = el.tagName().indexOf(":");
+            int pos = el.tagName().indexOf(':');
             return pos > 0 ? el.tagName().substring(0, pos) : "";
         }
 
diff --git a/src/main/java/org/jsoup/internal/StringUtil.java b/src/main/java/org/jsoup/internal/StringUtil.java
index f23c8723..53be1f95 100644
--- a/src/main/java/org/jsoup/internal/StringUtil.java
+++ b/src/main/java/org/jsoup/internal/StringUtil.java
@@ -9,6 +9,7 @@ import java.util.Arrays;
 import java.util.Collection;
 import java.util.Iterator;
 import java.util.Stack;
+import java.util.regex.Pattern;
 
 /**
  A minimal String utility class. Designed for <b>internal</b> jsoup use only - the API and outcome may change without
@@ -259,6 +260,7 @@ public final class StringUtil {
         return true;
     }
 
+    private static Pattern extraDotSegmentsPattern = Pattern.compile("^/((\\.{1,2}/)+)");
     /**
      * Create a new absolute URL, from a provided existing absolute URL and a relative URL component.
      * @param base the existing absolute base URL
@@ -271,10 +273,12 @@ public final class StringUtil {
         if (relUrl.startsWith("?"))
             relUrl = base.getPath() + relUrl;
         // workaround: //example.com + ./foo = //example.com/./foo, not //example.com/foo
-        if (relUrl.indexOf('.') == 0 && base.getFile().indexOf('/') != 0) {
-            base = new URL(base.getProtocol(), base.getHost(), base.getPort(), "/" + base.getFile());
+        URL url = new URL(base, relUrl);
+        String fixedFile = extraDotSegmentsPattern.matcher(url.getFile()).replaceFirst("/");
+        if (url.getRef() != null) {
+            fixedFile = fixedFile + "#" + url.getRef();
         }
-        return new URL(base, relUrl);
+        return new URL(url.getProtocol(), url.getHost(), url.getPort(), fixedFile);
     }
 
     /**
diff --git a/src/main/java/org/jsoup/nodes/Attribute.java b/src/main/java/org/jsoup/nodes/Attribute.java
index 3bbc24a8..fa1331a4 100644
--- a/src/main/java/org/jsoup/nodes/Attribute.java
+++ b/src/main/java/org/jsoup/nodes/Attribute.java
@@ -174,7 +174,7 @@ public class Attribute implements Map.Entry<String, String>, Cloneable  {
     protected static boolean shouldCollapseAttribute(final String key, @Nullable final String val, final Document.OutputSettings out) {
         return (
             out.syntax() == Document.OutputSettings.Syntax.html &&
-                (val == null || ("".equals(val) || val.equalsIgnoreCase(key)) && Attribute.isBooleanAttribute(key)));
+                (val == null || (val.isEmpty() || val.equalsIgnoreCase(key)) && Attribute.isBooleanAttribute(key)));
     }
 
     /**
diff --git a/src/main/java/org/jsoup/nodes/Attributes.java b/src/main/java/org/jsoup/nodes/Attributes.java
index b2dc18ef..6e0d7898 100644
--- a/src/main/java/org/jsoup/nodes/Attributes.java
+++ b/src/main/java/org/jsoup/nodes/Attributes.java
@@ -46,6 +46,7 @@ public class Attributes implements Iterable<Attribute>, Cloneable {
     static final int NotFound = -1;
     private static final String EmptyString = "";
 
+    // the number of instance fields is kept as low as possible giving an object size of 24 bytes
     private int size = 0; // number of slots used (not total capacity, which is keys.length)
     String[] keys = new String[InitialCapacity];
     String[] vals = new String[InitialCapacity];
@@ -246,16 +247,12 @@ public class Attributes implements Iterable<Attribute>, Cloneable {
     }
 
     /**
-     Get the number of attributes in this set.
+     Get the number of attributes in this set, including any jsoup internal-only attributes. Internal attributes are
+     excluded from the {@link #html()}, {@link #asList()}, and {@link #iterator()} methods.
      @return size
      */
     public int size() {
-        int s = 0;
-        for (int i = 0; i < size; i++) {
-            if (!isInternalKey(keys[i]))
-                s++;
-        }
-        return s;
+        return size;
     }
 
     /**
diff --git a/src/main/java/org/jsoup/nodes/Comment.java b/src/main/java/org/jsoup/nodes/Comment.java
index 0ecfa3a1..33f5c191 100644
--- a/src/main/java/org/jsoup/nodes/Comment.java
+++ b/src/main/java/org/jsoup/nodes/Comment.java
@@ -1,6 +1,6 @@
 package org.jsoup.nodes;
 
-import org.jsoup.Jsoup;
+import org.jsoup.parser.ParseSettings;
 import org.jsoup.parser.Parser;
 
 import javax.annotation.Nullable;
@@ -63,6 +63,10 @@ public class Comment extends LeafNode {
      */
     public boolean isXmlDeclaration() {
         String data = getData();
+        return isXmlDeclarationData(data);
+    }
+
+    private static boolean isXmlDeclarationData(String data) {
         return (data.length() > 1 && (data.startsWith("!") || data.startsWith("?")));
     }
 
@@ -72,10 +76,18 @@ public class Comment extends LeafNode {
      */
     public @Nullable XmlDeclaration asXmlDeclaration() {
         String data = getData();
-        Document doc = Jsoup.parse("<" + data.substring(1, data.length() -1) + ">", baseUri(), Parser.xmlParser());
+
         XmlDeclaration decl = null;
-        if (doc.children().size() > 0) {
-            Element el = doc.child(0);
+        String declContent = data.substring(1, data.length() - 1);
+        // make sure this bogus comment is not immediately followed by another, treat as comment if so
+        if (isXmlDeclarationData(declContent))
+            return null;
+
+        String fragment = "<" + declContent + ">";
+        // use the HTML parser not XML, so we don't get into a recursive XML Declaration on contrived data
+        Document doc = Parser.htmlParser().settings(ParseSettings.preserveCase).parseInput(fragment, baseUri());
+        if (doc.body().children().size() > 0) {
+            Element el = doc.body().child(0);
             decl = new XmlDeclaration(NodeUtils.parser(doc).settings().normalizeTag(el.tagName()), data.startsWith("!"));
             decl.attributes().addAll(el.attributes());
         }
diff --git a/src/main/java/org/jsoup/nodes/DataNode.java b/src/main/java/org/jsoup/nodes/DataNode.java
index 336cc6a3..2c37e756 100644
--- a/src/main/java/org/jsoup/nodes/DataNode.java
+++ b/src/main/java/org/jsoup/nodes/DataNode.java
@@ -57,9 +57,11 @@ public class DataNode extends LeafNode {
     /**
      Create a new DataNode from HTML encoded data.
      @param encodedData encoded data
-     @param baseUri bass URI
+     @param baseUri base URI
      @return new DataNode
+     @deprecated Unused, and will be removed in 1.15.1.
      */
+    @Deprecated
     public static DataNode createFromEncoded(String encodedData, String baseUri) {
         String data = Entities.unescape(encodedData);
         return new DataNode(data);
diff --git a/src/main/java/org/jsoup/nodes/Entities.java b/src/main/java/org/jsoup/nodes/Entities.java
index 7c0c15b6..caa78e29 100644
--- a/src/main/java/org/jsoup/nodes/Entities.java
+++ b/src/main/java/org/jsoup/nodes/Entities.java
@@ -3,6 +3,7 @@ package org.jsoup.nodes;
 import org.jsoup.SerializationException;
 import org.jsoup.internal.StringUtil;
 import org.jsoup.helper.Validate;
+import org.jsoup.nodes.Document.OutputSettings;
 import org.jsoup.parser.CharacterReader;
 import org.jsoup.parser.Parser;
 
@@ -11,6 +12,7 @@ import java.nio.charset.CharsetEncoder;
 import java.util.Arrays;
 import java.util.HashMap;
 
+import static org.jsoup.nodes.Document.OutputSettings.*;
 import static org.jsoup.nodes.Entities.EscapeMode.base;
 import static org.jsoup.nodes.Entities.EscapeMode.extended;
 
@@ -24,7 +26,7 @@ public class Entities {
     static final int codepointRadix = 36;
     private static final char[] codeDelims = {',', ';'};
     private static final HashMap<String, String> multipoints = new HashMap<>(); // name -> multiple character references
-    private static final Document.OutputSettings DefaultOutput = new Document.OutputSettings();
+    private static final OutputSettings DefaultOutput = new OutputSettings();
 
     public enum EscapeMode {
         /**
@@ -135,7 +137,7 @@ public class Entities {
      * @param out the output settings to use
      * @return the escaped string
      */
-    public static String escape(String string, Document.OutputSettings out) {
+    public static String escape(String string, OutputSettings out) {
         if (string == null)
             return "";
         StringBuilder accum = StringUtil.borrowBuilder();
@@ -159,7 +161,7 @@ public class Entities {
     }
 
     // this method is ugly, and does a lot. but other breakups cause rescanning and stringbuilder generations
-    static void escape(Appendable accum, String string, Document.OutputSettings out,
+    static void escape(Appendable accum, String string, OutputSettings out,
                        boolean inAttribute, boolean normaliseWhite, boolean stripLeadingWhite) throws IOException {
 
         boolean lastWasWhite = false;
@@ -200,8 +202,8 @@ public class Entities {
                             accum.append("&#xa0;");
                         break;
                     case '<':
-                        // escape when in character data or when in a xml attribue val; not needed in html attr val
-                        if (!inAttribute || escapeMode == EscapeMode.xhtml)
+                        // escape when in character data or when in a xml attribute val or XML syntax; not needed in html attr val
+                        if (!inAttribute || escapeMode == EscapeMode.xhtml || out.syntax() == Syntax.xml)
                             accum.append("&lt;");
                         else
                             accum.append(c);
@@ -308,37 +310,40 @@ public class Entities {
 
         int i = 0;
         CharacterReader reader = new CharacterReader(pointsData);
+        try {
+            while (!reader.isEmpty()) {
+                // NotNestedLessLess=10913,824;1887&
 
-        while (!reader.isEmpty()) {
-            // NotNestedLessLess=10913,824;1887&
-
-            final String name = reader.consumeTo('=');
-            reader.advance();
-            final int cp1 = Integer.parseInt(reader.consumeToAny(codeDelims), codepointRadix);
-            final char codeDelim = reader.current();
-            reader.advance();
-            final int cp2;
-            if (codeDelim == ',') {
-                cp2 = Integer.parseInt(reader.consumeTo(';'), codepointRadix);
+                final String name = reader.consumeTo('=');
+                reader.advance();
+                final int cp1 = Integer.parseInt(reader.consumeToAny(codeDelims), codepointRadix);
+                final char codeDelim = reader.current();
+                reader.advance();
+                final int cp2;
+                if (codeDelim == ',') {
+                    cp2 = Integer.parseInt(reader.consumeTo(';'), codepointRadix);
+                    reader.advance();
+                } else {
+                    cp2 = empty;
+                }
+                final String indexS = reader.consumeTo('&');
+                final int index = Integer.parseInt(indexS, codepointRadix);
                 reader.advance();
-            } else {
-                cp2 = empty;
-            }
-            final String indexS = reader.consumeTo('&');
-            final int index = Integer.parseInt(indexS, codepointRadix);
-            reader.advance();
 
-            e.nameKeys[i] = name;
-            e.codeVals[i] = cp1;
-            e.codeKeys[index] = cp1;
-            e.nameVals[index] = name;
+                e.nameKeys[i] = name;
+                e.codeVals[i] = cp1;
+                e.codeKeys[index] = cp1;
+                e.nameVals[index] = name;
 
-            if (cp2 != empty) {
-                multipoints.put(name, new String(new int[]{cp1, cp2}, 0, 2));
+                if (cp2 != empty) {
+                    multipoints.put(name, new String(new int[]{cp1, cp2}, 0, 2));
+                }
+                i++;
             }
-            i++;
-        }
 
-        Validate.isTrue(i == size, "Unexpected count of entities loaded");
+            Validate.isTrue(i == size, "Unexpected count of entities loaded");
+        } finally {
+            reader.close();
+        }
     }
 }
diff --git a/src/main/java/org/jsoup/nodes/Node.java b/src/main/java/org/jsoup/nodes/Node.java
index 216e5fdc..dd0a190c 100644
--- a/src/main/java/org/jsoup/nodes/Node.java
+++ b/src/main/java/org/jsoup/nodes/Node.java
@@ -111,7 +111,7 @@ public abstract class Node implements Cloneable {
 
         if (attributeKey.startsWith("abs:")) {
             String key = attributeKey.substring("abs:".length());
-            if (attributes().hasKeyIgnoreCase(key) && !absUrl(key).equals(""))
+            if (attributes().hasKeyIgnoreCase(key) && !absUrl(key).isEmpty())
                 return true;
         }
         return attributes().hasKeyIgnoreCase(attributeKey);
@@ -193,12 +193,10 @@ public abstract class Node implements Cloneable {
      */
     public String absUrl(String attributeKey) {
         Validate.notEmpty(attributeKey);
+        if (!(hasAttributes() && attributes().hasKeyIgnoreCase(attributeKey))) // not using hasAttr, so that we don't recurse down hasAttr->absUrl
+            return "";
 
-        if (!hasAttr(attributeKey)) {
-            return ""; // nothing to make absolute with
-        } else {
-            return StringUtil.resolve(baseUri(), attr(attributeKey));
-        }
+        return StringUtil.resolve(baseUri(), attributes().getIgnoreCase(attributeKey));
     }
 
     protected abstract List<Node> ensureChildNodes();
@@ -710,6 +708,7 @@ public abstract class Node implements Cloneable {
      * @return a stand-alone cloned node, including clones of any children
      * @see #shallowClone()
      */
+    @SuppressWarnings("MethodDoesntCallSuperMethod") // because it does call super.clone in doClone - analysis just isn't following
     @Override
     public Node clone() {
         Node thisClone = doClone(null); // splits for orphan
diff --git a/src/main/java/org/jsoup/nodes/XmlDeclaration.java b/src/main/java/org/jsoup/nodes/XmlDeclaration.java
index d7906eb6..6ab9b162 100644
--- a/src/main/java/org/jsoup/nodes/XmlDeclaration.java
+++ b/src/main/java/org/jsoup/nodes/XmlDeclaration.java
@@ -52,9 +52,17 @@ public class XmlDeclaration extends LeafNode {
 
     private void getWholeDeclaration(Appendable accum, Document.OutputSettings out) throws IOException {
         for (Attribute attribute : attributes()) {
-            if (!attribute.getKey().equals(nodeName())) { // skips coreValue (name)
+            String key = attribute.getKey();
+            String val = attribute.getValue();
+            if (!key.equals(nodeName())) { // skips coreValue (name)
                 accum.append(' ');
-                attribute.html(accum, out);
+                // basically like Attribute, but skip empty vals in XML
+                accum.append(key);
+                if (!val.isEmpty()) {
+                    accum.append("=\"");
+                    Entities.escape(accum, val, out, true, false, false);
+                    accum.append('"');
+                }
             }
         }
     }
diff --git a/src/main/java/org/jsoup/parser/CharacterReader.java b/src/main/java/org/jsoup/parser/CharacterReader.java
index 94710fd0..e2297494 100644
--- a/src/main/java/org/jsoup/parser/CharacterReader.java
+++ b/src/main/java/org/jsoup/parser/CharacterReader.java
@@ -137,9 +137,12 @@ public final class CharacterReader {
         return val;
     }
 
+    /**
+     Unconsume one character (bufPos--). MUST only be called directly after a consume(), and no chance of a bufferUp.
+     */
     void unconsume() {
         if (bufPos < 1)
-            throw new UncheckedIOException(new IOException("No buffer left to unconsume"));
+            throw new UncheckedIOException(new IOException("WTF: No buffer left to unconsume.")); // a bug if this fires, need to trace it.
 
         bufPos--;
     }
@@ -359,8 +362,8 @@ public final class CharacterReader {
     }
 
     String consumeTagName() {
-        // '\t', '\n', '\r', '\f', ' ', '/', '>', nullChar
-        // NOTE: out of spec, added '<' to fix common author bugs
+        // '\t', '\n', '\r', '\f', ' ', '/', '>'
+        // NOTE: out of spec, added '<' to fix common author bugs; does not stop and append on nullChar but eats
         bufferUp();
         int pos = bufPos;
         final int start = pos;
@@ -377,7 +380,6 @@ public final class CharacterReader {
                 case '/':
                 case '>':
                 case '<':
-                case TokeniserState.nullChar:
                     break OUTER;
             }
             pos++;
@@ -553,7 +555,7 @@ public final class CharacterReader {
     }
 
     /**
-     * Caches short strings, as a flywheel pattern, to reduce GC load. Just for this doc, to prevent leaks.
+     * Caches short strings, as a flyweight pattern, to reduce GC load. Just for this doc, to prevent leaks.
      * <p />
      * Simplistic, and on hash collisions just falls back to creating a new string, vs a full HashMap with Entry list.
      * That saves both having to create objects as hash keys, and running through the entry list, at the expense of
@@ -567,27 +569,22 @@ public final class CharacterReader {
             return "";
 
         // calculate hash:
-        int hash = 31 * count;
-        int offset = start;
+        int hash = 0;
         for (int i = 0; i < count; i++) {
-            hash = 31 * hash + charBuf[offset++];
+            hash = 31 * hash + charBuf[start + i];
         }
 
         // get from cache
         final int index = hash & stringCacheSize - 1;
         String cached = stringCache[index];
 
-        if (cached == null) { // miss, add
+        if (cached != null && rangeEquals(charBuf, start, count, cached)) // positive hit
+            return cached;
+        else {
             cached = new String(charBuf, start, count);
-            stringCache[index] = cached;
-        } else { // hashcode hit, check equality
-            if (rangeEquals(charBuf, start, count, cached)) { // hit
-                return cached;
-            } else { // hashcode conflict
-                cached = new String(charBuf, start, count);
-                stringCache[index] = cached; // update the cache, as recently used strings are more likely to show up again
-            }
+            stringCache[index] = cached; // add or replace, assuming most recently used are most likely to recur next
         }
+
         return cached;
     }
 
diff --git a/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java b/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
index 6d8c7142..ed533b8d 100644
--- a/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
+++ b/src/main/java/org/jsoup/parser/HtmlTreeBuilder.java
@@ -47,9 +47,9 @@ public class HtmlTreeBuilder extends TreeBuilder {
     private HtmlTreeBuilderState originalState; // original / marked state
 
     private boolean baseUriSetFromDoc;
-    private Element headElement; // the current head element
-    private FormElement formElement; // the current form element
-    private Element contextElement; // fragment parse context -- could be null even if fragment parsing
+    private @Nullable Element headElement; // the current head element
+    private @Nullable FormElement formElement; // the current form element
+    private @Nullable Element contextElement; // fragment parse context -- could be null even if fragment parsing
     private ArrayList<Element> formattingElements; // active (open) formatting elements
     private List<String> pendingTableCharacters; // chars in table to be shifted out
     private Token.EndTag emptyEnd; // reused empty end tag
@@ -322,8 +322,11 @@ public class HtmlTreeBuilder extends TreeBuilder {
         return isElementInQueue(stack, el);
     }
 
+    private static final int maxQueueDepth = 256; // an arbitrary tension point between real HTML and crafted pain
     private boolean isElementInQueue(ArrayList<Element> queue, Element element) {
-        for (int pos = queue.size() -1; pos >= 0; pos--) {
+        final int bottom = queue.size() - 1;
+        final int upper = bottom >= maxQueueDepth ? bottom - maxQueueDepth : 0;
+        for (int pos = bottom; pos >= upper; pos--) {
             Element next = queue.get(pos);
             if (next == element) {
                 return true;
@@ -353,6 +356,7 @@ public class HtmlTreeBuilder extends TreeBuilder {
         return false;
     }
 
+    @Nullable
     Element popStackToClose(String elName) {
         for (int pos = stack.size() -1; pos >= 0; pos--) {
             Element el = stack.get(pos);
@@ -434,17 +438,20 @@ public class HtmlTreeBuilder extends TreeBuilder {
     }
 
     void resetInsertionMode() {
+        // https://html.spec.whatwg.org/multipage/parsing.html#the-insertion-mode
         boolean last = false;
         for (int pos = stack.size() -1; pos >= 0; pos--) {
             Element node = stack.get(pos);
             if (pos == 0) {
                 last = true;
-                node = contextElement;
+                if (fragmentParsing)
+                    node = contextElement;
             }
-            String name = node.normalName();
+            String name = node != null ? node.normalName() : "";
             if ("select".equals(name)) {
                 transition(HtmlTreeBuilderState.InSelect);
-                break; // frag
+                // todo - should loop up (with some limit) and check for table or template hits
+                break;
             } else if (("td".equals(name) || "th".equals(name) && !last)) {
                 transition(HtmlTreeBuilderState.InCell);
                 break;
@@ -459,25 +466,26 @@ public class HtmlTreeBuilder extends TreeBuilder {
                 break;
             } else if ("colgroup".equals(name)) {
                 transition(HtmlTreeBuilderState.InColumnGroup);
-                break; // frag
+                break;
             } else if ("table".equals(name)) {
                 transition(HtmlTreeBuilderState.InTable);
                 break;
-            } else if ("head".equals(name)) {
-                transition(HtmlTreeBuilderState.InBody);
-                break; // frag
+            // todo - template
+            } else if ("head".equals(name) && !last) {
+                transition(HtmlTreeBuilderState.InHead);
+                break;
             } else if ("body".equals(name)) {
                 transition(HtmlTreeBuilderState.InBody);
                 break;
             } else if ("frameset".equals(name)) {
                 transition(HtmlTreeBuilderState.InFrameset);
-                break; // frag
+                break;
             } else if ("html".equals(name)) {
-                transition(HtmlTreeBuilderState.BeforeHead);
-                break; // frag
+                transition(headElement == null ? HtmlTreeBuilderState.BeforeHead : HtmlTreeBuilderState.AfterHead);
+                break;
             } else if (last) {
                 transition(HtmlTreeBuilderState.InBody);
-                break; // frag
+                break;
             }
         }
     }
@@ -610,6 +618,14 @@ public class HtmlTreeBuilder extends TreeBuilder {
         return formattingElements.size() > 0 ? formattingElements.get(formattingElements.size()-1) : null;
     }
 
+    int positionOfElement(Element el){
+        for (int i = 0; i < formattingElements.size(); i++){
+            if (el == formattingElements.get(i))
+                return i;
+        }
+        return -1;
+    }
+
     Element removeLastFormattingElement() {
         int size = formattingElements.size();
         if (size > 0)
@@ -620,6 +636,21 @@ public class HtmlTreeBuilder extends TreeBuilder {
 
     // active formatting elements
     void pushActiveFormattingElements(Element in) {
+        checkActiveFormattingElements(in);
+        formattingElements.add(in);
+    }
+
+    void pushWithBookmark(Element in, int bookmark){
+        checkActiveFormattingElements(in);
+        // catch any range errors and assume bookmark is incorrect - saves a redundant range check.
+        try {
+            formattingElements.add(bookmark, in);
+        } catch (IndexOutOfBoundsException e) {
+            formattingElements.add(in);
+        }
+    }
+
+    void checkActiveFormattingElements(Element in){
         int numSeen = 0;
         for (int pos = formattingElements.size() -1; pos >= 0; pos--) {
             Element el = formattingElements.get(pos);
@@ -634,7 +665,6 @@ public class HtmlTreeBuilder extends TreeBuilder {
                 break;
             }
         }
-        formattingElements.add(in);
     }
 
     private boolean isSameFormattingElement(Element a, Element b) {
diff --git a/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java b/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java
index cab017a1..8a0f77ef 100644
--- a/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java
+++ b/src/main/java/org/jsoup/parser/HtmlTreeBuilderState.java
@@ -123,7 +123,7 @@ enum HtmlTreeBuilderState {
                         if (name.equals("base") && el.hasAttr("href"))
                             tb.maybeSetBaseUri(el);
                     } else if (name.equals("meta")) {
-                        Element meta = tb.insertEmpty(start);
+                        tb.insertEmpty(start);
                         // todo: charset switches
                     } else if (name.equals("title")) {
                         handleRcData(start, tb);
@@ -806,11 +806,14 @@ enum HtmlTreeBuilderState {
                 // the spec doesn't limit to < 64, but in degenerate cases (9000+ stack depth) this prevents
                 // run-aways
                 final int stackSize = stack.size();
+                int bookmark = -1;
                 for (int si = 0; si < stackSize && si < 64; si++) {
                     el = stack.get(si);
                     if (el == formatEl) {
                         commonAncestor = stack.get(si - 1);
                         seenFormattingElement = true;
+                        // Let a bookmark note the position of the formatting element in the list of active formatting elements relative to the elements on either side of it in the list.
+                        bookmark = tb.positionOfElement(el);
                     } else if (seenFormattingElement && tb.isSpecial(el)) {
                         furthestBlock = el;
                         break;
@@ -822,8 +825,6 @@ enum HtmlTreeBuilderState {
                     return true;
                 }
 
-                // todo: Let a bookmark note the position of the formatting element in the list of active formatting elements relative to the elements on either side of it in the list.
-                // does that mean: int pos of format el in list?
                 Element node = furthestBlock;
                 Element lastNode = furthestBlock;
                 for (int j = 0; j < 3; j++) {
@@ -841,10 +842,10 @@ enum HtmlTreeBuilderState {
                     tb.replaceOnStack(node, replacement);
                     node = replacement;
 
-                    //noinspection StatementWithEmptyBody
                     if (lastNode == furthestBlock) {
-                        // todo: move the aforementioned bookmark to be immediately after the new node in the list of active formatting elements.
+                        // move the aforementioned bookmark to be immediately after the new node in the list of active formatting elements.
                         // not getting how this bookmark both straddles the element above, but is inbetween here...
+                        bookmark = tb.positionOfElement(node) + 1;
                     }
                     if (lastNode.parent() != null)
                         lastNode.remove();
@@ -853,14 +854,16 @@ enum HtmlTreeBuilderState {
                     lastNode = node;
                 }
 
-                if (inSorted(commonAncestor.normalName(), Constants.InBodyEndTableFosters)) {
-                    if (lastNode.parent() != null)
-                        lastNode.remove();
-                    tb.insertInFosterParent(lastNode);
-                } else {
-                    if (lastNode.parent() != null)
-                        lastNode.remove();
-                    commonAncestor.appendChild(lastNode);
+                if (commonAncestor != null) { // safety check, but would be an error if null
+                    if (inSorted(commonAncestor.normalName(), Constants.InBodyEndTableFosters)) {
+                        if (lastNode.parent() != null)
+                            lastNode.remove();
+                        tb.insertInFosterParent(lastNode);
+                    } else {
+                        if (lastNode.parent() != null)
+                            lastNode.remove();
+                        commonAncestor.appendChild(lastNode);
+                    }
                 }
 
                 Element adopter = new Element(formatEl.tag(), tb.getBaseUri());
@@ -871,7 +874,8 @@ enum HtmlTreeBuilderState {
                 }
                 furthestBlock.appendChild(adopter);
                 tb.removeFromActiveFormattingElements(formatEl);
-                // todo: insert the new element into the list of active formatting elements at the position of the aforementioned bookmark.
+                // insert the new element into the list of active formatting elements at the position of the aforementioned bookmark.
+                tb.pushWithBookmark(adopter, bookmark);
                 tb.removeFromStack(formatEl);
                 tb.insertOnStackAfter(furthestBlock, adopter);
             }
@@ -899,7 +903,7 @@ enum HtmlTreeBuilderState {
     },
     InTable {
         boolean process(Token t, HtmlTreeBuilder tb) {
-            if (t.isCharacter()) {
+            if (t.isCharacter() && inSorted(tb.currentElement().normalName(), InTableFoster)) {
                 tb.newPendingTableCharacters();
                 tb.markInsertionMode();
                 tb.transition(InTableText);
@@ -923,6 +927,7 @@ enum HtmlTreeBuilderState {
                     tb.insert(startTag);
                     tb.transition(InColumnGroup);
                 } else if (name.equals("col")) {
+                    tb.clearStackToTableContext();
                     tb.processStartTag("colgroup");
                     return tb.process(t);
                 } else if (inSorted(name, InTableToBody)) {
@@ -930,13 +935,23 @@ enum HtmlTreeBuilderState {
                     tb.insert(startTag);
                     tb.transition(InTableBody);
                 } else if (inSorted(name, InTableAddBody)) {
+                    tb.clearStackToTableContext();
                     tb.processStartTag("tbody");
                     return tb.process(t);
                 } else if (name.equals("table")) {
                     tb.error(this);
-                    boolean processed = tb.processEndTag("table");
-                    if (processed) // only ignored if in fragment
+                    if (!tb.inTableScope(name)) { // ignore it
+                        return false;
+                    } else {
+                        tb.popStackToClose(name);
+                        tb.resetInsertionMode();
+                        if (tb.state() == InTable) {
+                            // not per spec - but haven't transitioned out of table. so try something else
+                            tb.insert(startTag);
+                            return true;
+                        }
                         return tb.process(t);
+                    }
                 } else if (inSorted(name, InTableToHead)) {
                     return tb.process(t, InHead);
                 } else if (name.equals("input")) {
@@ -966,8 +981,8 @@ enum HtmlTreeBuilderState {
                         return false;
                     } else {
                         tb.popStackToClose("table");
+                        tb.resetInsertionMode();
                     }
-                    tb.resetInsertionMode();
                 } else if (inSorted(name, InTableEndErr)) {
                     tb.error(this);
                     return false;
@@ -985,15 +1000,10 @@ enum HtmlTreeBuilderState {
 
         boolean anythingElse(Token t, HtmlTreeBuilder tb) {
             tb.error(this);
-            boolean processed;
-            if (inSorted(tb.currentElement().normalName(), InTableFoster)) {
-                tb.setFosterInserts(true);
-                processed = tb.process(t, InBody);
-                tb.setFosterInserts(false);
-            } else {
-                processed = tb.process(t, InBody);
-            }
-            return processed;
+            tb.setFosterInserts(true);
+            tb.process(t, InBody);
+            tb.setFosterInserts(false);
+            return true;
         }
     },
     InTableText {
@@ -1214,12 +1224,13 @@ enum HtmlTreeBuilderState {
                 } else if (name.equals("table")) {
                     return handleMissingTr(t, tb);
                 } else if (inSorted(name, InTableToBody)) {
-                    if (!tb.inTableScope(name)) {
+                    if (!tb.inTableScope(name) || !tb.inTableScope("tr")) {
                         tb.error(this);
                         return false;
                     }
-                    tb.processEndTag("tr");
-                    return tb.process(t);
+                    tb.clearStackToTableRowContext();
+                    tb.pop(); // tr
+                    tb.transition(InTableBody);
                 } else if (inSorted(name, InRowIgnore)) {
                     tb.error(this);
                     return false;
@@ -1524,8 +1535,11 @@ enum HtmlTreeBuilderState {
                 //  that space into body if other tags get re-added. but that's overkill for now
                 Element html = tb.popStackToClose("html");
                 tb.insert(t.asCharacter());
-                tb.stack.add(html);
-                tb.stack.add(html.selectFirst("body"));
+                if (html != null) {
+                    tb.stack.add(html);
+                    Element body = html.selectFirst("body");
+                    if (body != null) tb.stack.add(body);
+                }
             }else if (t.isEOF()) {
                 // nice work chuck
             } else {
diff --git a/src/main/java/org/jsoup/parser/Token.java b/src/main/java/org/jsoup/parser/Token.java
index 78318d8d..d1b38b91 100644
--- a/src/main/java/org/jsoup/parser/Token.java
+++ b/src/main/java/org/jsoup/parser/Token.java
@@ -97,11 +97,16 @@ abstract class Token {
             return this;
         }
 
+        /* Limits runaway crafted HTML from spewing attributes and getting a little sluggish in ensureCapacity.
+        Real-world HTML will P99 around 8 attributes, so plenty of headroom. Implemented here and not in the Attributes
+        object so that API users can add more if ever required. */
+        private static final int MaxAttributes = 512;
+
         final void newAttribute() {
             if (attributes == null)
                 attributes = new Attributes();
 
-            if (pendingAttributeName != null) {
+            if (pendingAttributeName != null && attributes.size() < MaxAttributes) {
                 // the tokeniser has skipped whitespace control chars, but trimming could collapse to empty for other control codes, so verify here
                 pendingAttributeName = pendingAttributeName.trim();
                 if (pendingAttributeName.length() > 0) {
@@ -165,6 +170,8 @@ abstract class Token {
 
         // these appenders are rarely hit in not null state-- caused by null chars.
         final void appendTagName(String append) {
+            // might have null chars - need to replace with null replacement character
+            append = append.replace(TokeniserState.nullChar, Tokeniser.replacementChar);
             tagName = tagName == null ? append : tagName.concat(append);
             normalName = lowerCase(tagName);
         }
@@ -174,6 +181,8 @@ abstract class Token {
         }
 
         final void appendAttributeName(String append) {
+            // might have null chars because we eat in one pass - need to replace with null replacement character
+            append = append.replace(TokeniserState.nullChar, Tokeniser.replacementChar);
             pendingAttributeName = pendingAttributeName == null ? append : pendingAttributeName.concat(append);
         }
 
diff --git a/src/main/java/org/jsoup/parser/TokenQueue.java b/src/main/java/org/jsoup/parser/TokenQueue.java
index f2690517..614ade19 100644
--- a/src/main/java/org/jsoup/parser/TokenQueue.java
+++ b/src/main/java/org/jsoup/parser/TokenQueue.java
@@ -38,7 +38,9 @@ public class TokenQueue {
     /**
      * Retrieves but does not remove the first character from the queue.
      * @return First character, or 0 if empty.
+     *@deprecated unused and will be removed in 1.15.x
      */
+    @Deprecated
     public char peek() {
         return isEmpty() ? 0 : queue.charAt(pos);
     }
@@ -46,7 +48,9 @@ public class TokenQueue {
     /**
      Add a character to the start of the queue (will be the next character retrieved).
      @param c character to add
+     @deprecated unused and will be removed in 1.15.x
      */
+    @Deprecated
     public void addFirst(Character c) {
         addFirst(c.toString());
     }
@@ -74,7 +78,9 @@ public class TokenQueue {
      * Case sensitive match test.
      * @param seq string to case sensitively check for
      * @return true if matched, false if not
+     * @deprecated unused and will be removed in 1.15.x
      */
+    @Deprecated
     public boolean matchesCS(String seq) {
         return queue.startsWith(seq, pos);
     }
@@ -104,6 +110,10 @@ public class TokenQueue {
         return false;
     }
 
+    /**
+     @deprecated unused and will be removed in 1.15.x
+     */
+    @Deprecated
     public boolean matchesStartTag() {
         // micro opt for matching "<x"
         return (remainingLength() >= 2 && queue.charAt(pos) == '<' && Character.isLetter(queue.charAt(pos+1)));
@@ -264,6 +274,7 @@ public class TokenQueue {
         char last = 0;
         boolean inSingleQuote = false;
         boolean inDoubleQuote = false;
+        boolean inRegexQE = false; // regex \Q .. \E escapes from Pattern.quote()
 
         do {
             if (isEmpty()) break;
@@ -273,8 +284,10 @@ public class TokenQueue {
                     inSingleQuote = !inSingleQuote;
                 else if (c == '"' && c != open && !inSingleQuote)
                     inDoubleQuote = !inDoubleQuote;
-                if (inSingleQuote || inDoubleQuote)
+                if (inSingleQuote || inDoubleQuote || inRegexQE){
+                    last = c;
                     continue;
+                }
 
                 if (c == open) {
                     depth++;
@@ -283,6 +296,10 @@ public class TokenQueue {
                 }
                 else if (c == close)
                     depth--;
+            } else if (c == 'Q') {
+                inRegexQE = true;
+            } else if (c == 'E') {
+                inRegexQE = false;
             }
 
             if (depth > 0 && last != 0)
@@ -344,7 +361,9 @@ public class TokenQueue {
      * Consume an tag name off the queue (word or :, _, -)
      * 
      * @return tag name
+     * @deprecated unused and will be removed in 1.15.x
      */
+    @Deprecated
     public String consumeTagName() {
         int start = pos;
         while (!isEmpty() && (matchesWord() || matchesAny(':', '_', '-')))
@@ -382,7 +401,9 @@ public class TokenQueue {
     /**
      Consume an attribute key off the queue (letter, digit, -, _, :")
      @return attribute key
+     @deprecated unused and will be removed in 1.15.x
      */
+    @Deprecated
     public String consumeAttributeKey() {
         int start = pos;
         while (!isEmpty() && (matchesWord() || matchesAny('-', '_', ':')))
diff --git a/src/main/java/org/jsoup/parser/TokeniserState.java b/src/main/java/org/jsoup/parser/TokeniserState.java
index 74e96fa5..845201fb 100644
--- a/src/main/java/org/jsoup/parser/TokeniserState.java
+++ b/src/main/java/org/jsoup/parser/TokeniserState.java
@@ -144,7 +144,6 @@ enum TokeniserState {
         // from < or </ in data, will have start or end tag pending
         void read(Tokeniser t, CharacterReader r) {
             // previous TagOpen state did NOT consume, will have a letter char in current
-            //String tagName = r.consumeToAnySorted(tagCharsSorted).toLowerCase();
             String tagName = r.consumeTagName();
             t.tagPending.appendTagName(tagName);
 
@@ -191,8 +190,7 @@ enum TokeniserState {
                 // consuming to EOF; break out here
                 t.tagPending = t.createTagPending(false).name(t.appropriateEndTagName());
                 t.emitTagPending();
-                r.unconsume(); // undo "<"
-                t.transition(Data);
+                t.transition(TagOpen); // straight into TagOpen, as we came from < and looks like we're on a start tag
             } else {
                 t.emit("<");
                 t.transition(Rcdata);
@@ -609,7 +607,7 @@ enum TokeniserState {
     AttributeName {
         // from before attribute name
         void read(Tokeniser t, CharacterReader r) {
-            String name = r.consumeToAnySorted(attributeNameCharsSorted);
+            String name = r.consumeToAnySorted(attributeNameCharsSorted); // spec deviate - consume and emit nulls in one hit vs stepping
             t.tagPending.appendAttributeName(name);
 
             char c = r.consume();
@@ -631,10 +629,6 @@ enum TokeniserState {
                     t.emitTagPending();
                     t.transition(Data);
                     break;
-                case nullChar:
-                    t.error(this);
-                    t.tagPending.appendAttributeName(replacementChar);
-                    break;
                 case eof:
                     t.eofError(this);
                     t.transition(Data);
@@ -1632,7 +1626,7 @@ enum TokeniserState {
 
     static final char nullChar = '\u0000';
     // char searches. must be sorted, used in inSorted. MUST update TokenisetStateTest if more arrays are added.
-    static final char[] attributeNameCharsSorted = new char[]{nullChar, '\t', '\n', '\f', '\r', ' ', '"', '\'', '/', '<', '=', '>'};
+    static final char[] attributeNameCharsSorted = new char[]{'\t', '\n', '\f', '\r', ' ', '"', '\'', '/', '<', '=', '>'};
     static final char[] attributeValueUnquoted = new char[]{nullChar, '\t', '\n', '\f', '\r', ' ', '"', '&', '\'', '<', '=', '>', '`'};
 
     private static final char replacementChar = Tokeniser.replacementChar;
diff --git a/src/main/java/org/jsoup/parser/XmlTreeBuilder.java b/src/main/java/org/jsoup/parser/XmlTreeBuilder.java
index 0b6d02d9..1c49f146 100644
--- a/src/main/java/org/jsoup/parser/XmlTreeBuilder.java
+++ b/src/main/java/org/jsoup/parser/XmlTreeBuilder.java
@@ -6,6 +6,7 @@ import org.jsoup.nodes.Comment;
 import org.jsoup.nodes.Document;
 import org.jsoup.nodes.DocumentType;
 import org.jsoup.nodes.Element;
+import org.jsoup.nodes.Entities;
 import org.jsoup.nodes.Node;
 import org.jsoup.nodes.TextNode;
 import org.jsoup.nodes.XmlDeclaration;
@@ -33,6 +34,7 @@ public class XmlTreeBuilder extends TreeBuilder {
         stack.add(doc); // place the document onto the stack. differs from HtmlTreeBuilder (not on stack)
         doc.outputSettings()
             .syntax(Document.OutputSettings.Syntax.xml)
+            .escapeMode(Entities.EscapeMode.xhtml)
             .prettyPrint(false); // as XML, we don't understand what whitespace is significant or not
     }
 
diff --git a/src/main/java/org/jsoup/select/NodeTraversor.java b/src/main/java/org/jsoup/select/NodeTraversor.java
index 74b66b4e..ae2b4580 100644
--- a/src/main/java/org/jsoup/select/NodeTraversor.java
+++ b/src/main/java/org/jsoup/select/NodeTraversor.java
@@ -18,6 +18,9 @@ public class NodeTraversor {
      * @param root the root node point to traverse.
      */
     public static void traverse(NodeVisitor visitor, Node root) {
+        Validate.notNull(visitor);
+        Validate.notNull(root);
+        
         Node node = root;
         Node parent; // remember parent to find nodes that get replaced in .head
         int depth = 0;
diff --git a/src/main/java/org/jsoup/select/QueryParser.java b/src/main/java/org/jsoup/select/QueryParser.java
index f06d10d3..fb2c0797 100644
--- a/src/main/java/org/jsoup/select/QueryParser.java
+++ b/src/main/java/org/jsoup/select/QueryParser.java
@@ -147,7 +147,10 @@ public class QueryParser {
             else if (tq.matches("["))
                 sq.append("[").append(tq.chompBalanced('[', ']')).append("]");
             else if (tq.matchesAny(combinators))
-                break;
+                if (sq.length() > 0)
+                    break;
+                else
+                    tq.consume();
             else
                 sq.append(tq.consume());
         }
@@ -237,7 +240,11 @@ public class QueryParser {
 
         // namespaces: wildcard match equals(tagName) or ending in ":"+tagName
         if (tagName.startsWith("*|")) {
-            evals.add(new CombiningEvaluator.Or(new Evaluator.Tag(tagName), new Evaluator.TagEndsWith(tagName.replace("*|", ":"))));
+            String plainTag = tagName.substring(2); // strip *|
+            evals.add(new CombiningEvaluator.Or(
+                new Evaluator.Tag(plainTag),
+                new Evaluator.TagEndsWith(tagName.replace("*|", ":")))
+            );
         } else {
             // namespaces: if element name is "abc:def", selector must be "abc|def", so flip:
             if (tagName.contains("|"))
diff --git a/src/main/java/org/jsoup/select/Selector.java b/src/main/java/org/jsoup/select/Selector.java
index 1ac4f1d8..0afd8c77 100644
--- a/src/main/java/org/jsoup/select/Selector.java
+++ b/src/main/java/org/jsoup/select/Selector.java
@@ -25,7 +25,7 @@ import java.util.IdentityHashMap;
  * <tr><th align="left">Pattern</th><th align="left">Matches</th><th align="left">Example</th></tr>
  * <tr><td><code>*</code></td><td>any element</td><td><code>*</code></td></tr>
  * <tr><td><code>tag</code></td><td>elements with the given tag name</td><td><code>div</code></td></tr>
- * <tr><td><code>*|E</code></td><td>elements of type E in any namespace <i>ns</i></td><td><code>*|name</code> finds <code>&lt;fb:name&gt;</code> elements</td></tr>
+ * <tr><td><code>*|E</code></td><td>elements of type E in any namespace (including non-namespaced)</td><td><code>*|name</code> finds <code>&lt;fb:name&gt;</code> and <code>&lt;name&gt;</code> elements</td></tr>
  * <tr><td><code>ns|E</code></td><td>elements of type E in the namespace <i>ns</i></td><td><code>fb|name</code> finds <code>&lt;fb:name&gt;</code> elements</td></tr>
  * <tr><td><code>#id</code></td><td>elements with attribute ID of "id"</td><td><code>div#wrap</code>, <code>#logo</code></td></tr>
  * <tr><td><code>.class</code></td><td>elements with a class name of "class"</td><td><code>div.left</code>, <code>.result</code></td></tr>
@@ -73,6 +73,8 @@ import java.util.IdentityHashMap;
  * <tr><td><code>:empty</code></td><td>elements that have no children at all</td><td></td></tr>
  * </table>
  *
+ * <p>A word on using regular expressions in these selectors: depending on the content of the regex, you will need to quote the pattern using <b><code>Pattern.quote("regex")</code></b> for it to parse correclty through both the selector parser and the regex parser. E.g. <code>String query = "div:matches(" + Pattern.quote(regex) + ");"</code>.</p>
+ *
  * @author Jonathan Hedley, jonathan@hedley.net
  * @see Element#select(String)
  */


Test patch:
diff --git a/src/test/java/org/jsoup/integration/FuzzFixesTest.java b/src/test/java/org/jsoup/integration/FuzzFixesTest.java
new file mode 100644
index 00000000..122cb59d
--- /dev/null
+++ b/src/test/java/org/jsoup/integration/FuzzFixesTest.java
@@ -0,0 +1,125 @@
+package org.jsoup.integration;
+
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.parser.Parser;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ Tests fixes for issues raised by the OSS Fuzz project @ https://oss-fuzz.com/testcases?project=jsoup
+ */
+public class FuzzFixesTest {
+
+    @Test
+    public void blankAbsAttr() {
+        // https://github.com/jhy/jsoup/issues/1541
+        String html = "b<bodY abs: abs:abs: abs:abs:abs>";
+        Document doc = Jsoup.parse(html);
+        assertNotNull(doc);
+    }
+
+    @Test
+    public void resetInsertionMode() throws IOException {
+        // https://github.com/jhy/jsoup/issues/1538
+        File in = ParseTest.getFile("/fuzztests/1538.html.gz"); // lots of escape chars etc.
+        Document doc = Jsoup.parse(in, "UTF-8");
+        assertNotNull(doc);
+    }
+
+    @Test
+    public void xmlDeclOverflow() throws IOException {
+        // https://github.com/jhy/jsoup/issues/1539
+        File in = ParseTest.getFile("/fuzztests/1539.html.gz"); // lots of escape chars etc.
+        Document doc = Jsoup.parse(in, "UTF-8");
+        assertNotNull(doc);
+
+        Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser());
+        assertNotNull(docXml);
+    }
+
+    @Test
+    public void xmlDeclOverflowOOM() throws IOException {
+        // https://github.com/jhy/jsoup/issues/1569
+        File in = ParseTest.getFile("/fuzztests/1569.html.gz");
+        Document doc = Jsoup.parse(in, "UTF-8");
+        assertNotNull(doc);
+
+        Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser());
+        assertNotNull(docXml);
+    }
+
+    @Test
+    public void stackOverflowState14() throws IOException {
+        // https://github.com/jhy/jsoup/issues/1543
+        File in = ParseTest.getFile("/fuzztests/1543.html.gz");
+        Document doc = Jsoup.parse(in, "UTF-8");
+        assertNotNull(doc);
+    }
+
+    @Test
+    public void parseTimeout() throws IOException {
+        // https://github.com/jhy/jsoup/issues/1544
+        File in = ParseTest.getFile("/fuzztests/1544.html.gz");
+        Document doc = Jsoup.parse(in, "UTF-8");
+        assertNotNull(doc);
+    }
+
+    @Test
+    public void parseTimeout1580() throws IOException {
+        // https://github.com/jhy/jsoup/issues/1580
+        // a shedload of NULLs in append tagname so was spinning in there. Fixed to eat and replace all the chars in one hit
+        File in = ParseTest.getFile("/fuzztests/1580.html.gz");
+        Document doc = Jsoup.parse(in, "UTF-8");
+        assertNotNull(doc);
+    }
+
+    @Test
+    public void bookmark() {
+        // https://github.com/jhy/jsoup/issues/1576
+        String html = "<?a<U<P<A ";
+        Document doc = Jsoup.parse(html);
+        assertNotNull(doc);
+
+        Document xmlDoc = Parser.xmlParser().parseInput(html, "");
+        assertNotNull(xmlDoc);
+    }
+
+    @Test
+    public void scope1579() {
+        // https://github.com/jhy/jsoup/issues/1579
+        String html = "<table<html\u001D<ÛÛ<tr><body\u001D<b:<select<m<input></html> </html>";
+        Document doc = Jsoup.parse(html);
+        assertNotNull(doc);
+
+        Document xmlDoc = Parser.xmlParser().parseInput(html, "");
+        assertNotNull(xmlDoc);
+    }
+
+    @Test
+    public void overflow1577() throws IOException {
+        // https://github.com/jhy/jsoup/issues/1577
+        File in = ParseTest.getFile("/fuzztests/1577.html.gz");
+        Document doc = Jsoup.parse(in, "UTF-8");
+        assertNotNull(doc);
+
+        Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser());
+        assertNotNull(docXml);
+    }
+
+    @Test
+    public void parseTimeout36150() throws IOException {
+        File in = ParseTest.getFile("/fuzztests/1580-attrname.html.gz");
+        // pretty much 1MB of null chars in text head
+        Document doc = Jsoup.parse(in, "UTF-8");
+        assertNotNull(doc);
+
+        Document docXml = Jsoup.parse(new FileInputStream(in), "UTF-8", "https://example.com", Parser.xmlParser());
+        assertNotNull(docXml);
+    }
+}
diff --git a/src/test/java/org/jsoup/internal/StringUtilTest.java b/src/test/java/org/jsoup/internal/StringUtilTest.java
index 55af0ff8..1956084b 100644
--- a/src/test/java/org/jsoup/internal/StringUtilTest.java
+++ b/src/test/java/org/jsoup/internal/StringUtilTest.java
@@ -99,6 +99,25 @@ public class StringUtilTest {
         assertEquals("ftp://example.com/one", resolve("ftp://example.com/two/", "../one"));
         assertEquals("ftp://example.com/one/two.c", resolve("ftp://example.com/one/", "./two.c"));
         assertEquals("ftp://example.com/one/two.c", resolve("ftp://example.com/one/", "two.c"));
+        // examples taken from rfc3986 section 5.4.2
+        assertEquals("http://example.com/g", resolve("http://example.com/b/c/d;p?q", "../../../g"));
+        assertEquals("http://example.com/g", resolve("http://example.com/b/c/d;p?q", "../../../../g"));
+        assertEquals("http://example.com/g", resolve("http://example.com/b/c/d;p?q", "/./g"));
+        assertEquals("http://example.com/g", resolve("http://example.com/b/c/d;p?q", "/../g"));
+        assertEquals("http://example.com/b/c/g.", resolve("http://example.com/b/c/d;p?q", "g."));
+        assertEquals("http://example.com/b/c/.g", resolve("http://example.com/b/c/d;p?q", ".g"));
+        assertEquals("http://example.com/b/c/g..", resolve("http://example.com/b/c/d;p?q", "g.."));
+        assertEquals("http://example.com/b/c/..g", resolve("http://example.com/b/c/d;p?q", "..g"));
+        assertEquals("http://example.com/b/g", resolve("http://example.com/b/c/d;p?q", "./../g"));
+        assertEquals("http://example.com/b/c/g/", resolve("http://example.com/b/c/d;p?q", "./g/."));
+        assertEquals("http://example.com/b/c/g/h", resolve("http://example.com/b/c/d;p?q", "g/./h"));
+        assertEquals("http://example.com/b/c/h", resolve("http://example.com/b/c/d;p?q", "g/../h"));
+        assertEquals("http://example.com/b/c/g;x=1/y", resolve("http://example.com/b/c/d;p?q", "g;x=1/./y"));
+        assertEquals("http://example.com/b/c/y", resolve("http://example.com/b/c/d;p?q", "g;x=1/../y"));
+        assertEquals("http://example.com/b/c/g?y/./x", resolve("http://example.com/b/c/d;p?q", "g?y/./x"));
+        assertEquals("http://example.com/b/c/g?y/../x", resolve("http://example.com/b/c/d;p?q", "g?y/../x"));
+        assertEquals("http://example.com/b/c/g#s/./x", resolve("http://example.com/b/c/d;p?q", "g#s/./x"));
+        assertEquals("http://example.com/b/c/g#s/../x", resolve("http://example.com/b/c/d;p?q", "g#s/../x"));
     }
 
     @Test
diff --git a/src/test/java/org/jsoup/nodes/AttributesTest.java b/src/test/java/org/jsoup/nodes/AttributesTest.java
index 84d9ed78..d2f986b0 100644
--- a/src/test/java/org/jsoup/nodes/AttributesTest.java
+++ b/src/test/java/org/jsoup/nodes/AttributesTest.java
@@ -225,6 +225,10 @@ public class AttributesTest {
 
         a.put(Attributes.internalKey("baseUri"), "example.com");
         a.put(Attributes.internalKey("another"), "example.com");
-        assertEquals(2, a.size());
+        a.put(Attributes.internalKey("last"), "example.com");
+        a.remove(Attributes.internalKey("last"));
+
+        assertEquals(4, a.size());
+        assertEquals(2, a.asList().size()); // excluded from lists
     }
 }
diff --git a/src/test/java/org/jsoup/nodes/DocumentTest.java b/src/test/java/org/jsoup/nodes/DocumentTest.java
index cdd26234..47b7ccbe 100644
--- a/src/test/java/org/jsoup/nodes/DocumentTest.java
+++ b/src/test/java/org/jsoup/nodes/DocumentTest.java
@@ -163,7 +163,7 @@ public class DocumentTest {
                 "<html>\n" +
                 " <head></head>\n" +
                 " <body>\n" +
-                "  <img async=\"\" checked=\"checked\" src=\"&amp;<>&quot;\" />&lt;&gt;&amp;\"<foo />bar\n" +
+                "  <img async=\"\" checked=\"checked\" src=\"&amp;&lt;>&quot;\" />&lt;&gt;&amp;\"<foo />bar\n" +
                 " </body>\n" +
                 "</html>", doc.html());
     }
diff --git a/src/test/java/org/jsoup/parser/HtmlTreeBuilderStateTest.java b/src/test/java/org/jsoup/parser/HtmlTreeBuilderStateTest.java
index 3aea3bdf..649340c3 100644
--- a/src/test/java/org/jsoup/parser/HtmlTreeBuilderStateTest.java
+++ b/src/test/java/org/jsoup/parser/HtmlTreeBuilderStateTest.java
@@ -1,5 +1,6 @@
 package org.jsoup.parser;
 
+import org.jsoup.Jsoup;
 import org.jsoup.parser.HtmlTreeBuilderState.Constants;
 import org.junit.jupiter.api.Test;
 
@@ -46,4 +47,54 @@ public class HtmlTreeBuilderStateTest {
         assertEquals(38, constants.size());
     }
 
+
+    @Test
+    public void nestedAnchorElements01() {
+        String html = "<html>\n" +
+                "  <body>\n" +
+                "    <a href='#1'>\n" +
+                "        <div>\n" +
+                "          <a href='#2'>child</a>\n" +
+                "        </div>\n" +
+                "    </a>\n" +
+                "  </body>\n" +
+                "</html>";
+        String s = Jsoup.parse(html).toString();
+        assertEquals("<html> \n" +
+                " <head></head>\n" +
+                " <body> <a href=\"#1\"> </a>\n" +
+                "  <div>\n" +
+                "   <a href=\"#1\"> </a><a href=\"#2\">child</a> \n" +
+                "  </div>   \n" +
+                " </body>\n" +
+                "</html>", s);
+    }
+
+    @Test
+    public void nestedAnchorElements02() {
+        String html = "<html>\n" +
+                "  <body>\n" +
+                "    <a href='#1'>\n" +
+                "      <div>\n" +
+                "        <div>\n" +
+                "          <a href='#2'>child</a>\n" +
+                "        </div>\n" +
+                "      </div>\n" +
+                "    </a>\n" +
+                "  </body>\n" +
+                "</html>";
+        String s = Jsoup.parse(html).toString();
+        assertEquals("<html> \n" +
+                " <head></head>\n" +
+                " <body> <a href=\"#1\"> </a>\n" +
+                "  <div>\n" +
+                "   <a href=\"#1\"> </a>\n" +
+                "   <div>\n" +
+                "    <a href=\"#1\"> </a><a href=\"#2\">child</a> \n" +
+                "   </div> \n" +
+                "  </div>   \n" +
+                " </body>\n" +
+                "</html>", s);
+    }
+
 }
diff --git a/src/test/java/org/jsoup/parser/ParserTest.java b/src/test/java/org/jsoup/parser/ParserTest.java
index f77b2306..7a97f2c6 100644
--- a/src/test/java/org/jsoup/parser/ParserTest.java
+++ b/src/test/java/org/jsoup/parser/ParserTest.java
@@ -1,7 +1,12 @@
 package org.jsoup.parser;
 
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
 import org.junit.jupiter.api.Test;
 
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
 public class ParserTest {
@@ -22,4 +27,12 @@ public class ParserTest {
         String body = longBody.toString();
         assertEquals(body, Parser.unescapeEntities(body, false));
     }
+
+    @Test
+    public void testUtf8() throws IOException {
+        // testcase for https://github.com/jhy/jsoup/issues/1557. no repro.
+        Document parsed = Jsoup.parse(new ByteArrayInputStream("<p>H\u00E9llo, w\u00F6rld!".getBytes("UTF-8")), null, "");
+        String text = parsed.selectFirst("p").wholeText();
+        assertEquals(text, "H\u00E9llo, w\u00F6rld!");
+    }
 }
diff --git a/src/test/java/org/jsoup/parser/TokenQueueTest.java b/src/test/java/org/jsoup/parser/TokenQueueTest.java
index 2690c4bf..463a19e4 100644
--- a/src/test/java/org/jsoup/parser/TokenQueueTest.java
+++ b/src/test/java/org/jsoup/parser/TokenQueueTest.java
@@ -1,8 +1,11 @@
 package org.jsoup.parser;
 
 import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
 import org.junit.jupiter.api.Test;
 
+import java.util.regex.Pattern;
+
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.fail;
 
@@ -95,4 +98,12 @@ public class TokenQueueTest {
             assertEquals("Did not find balanced marker at 'something(or another)) else'", expected.getMessage());
         }
     }
+
+    @Test
+    public void testQuotedPattern() {
+        final Document doc = Jsoup.parse("<div>\\) foo1</div><div>( foo2</div><div>1) foo3</div>");
+        assertEquals("\n\\) foo1",doc.select("div:matches(" + Pattern.quote("\\)") + ")").get(0).childNode(0).toString());
+        assertEquals("\n( foo2",doc.select("div:matches(" + Pattern.quote("(") + ")").get(0).childNode(0).toString());
+        assertEquals("\n1) foo3",doc.select("div:matches(" + Pattern.quote("1)") + ")").get(0).childNode(0).toString());
+    }
 }
diff --git a/src/test/java/org/jsoup/parser/TokeniserStateTest.java b/src/test/java/org/jsoup/parser/TokeniserStateTest.java
index d8a791f3..d34c38fc 100644
--- a/src/test/java/org/jsoup/parser/TokeniserStateTest.java
+++ b/src/test/java/org/jsoup/parser/TokeniserStateTest.java
@@ -218,6 +218,18 @@ public class TokeniserStateTest {
         assertEquals(CharacterReader.readAheadLimit - 1, errorList.get(0).getPosition());
     }
 
+    @Test
+    public void testUnconsumeAfterBufferUp() {
+        // test for after consume() a bufferUp occurs (look-forward) but then attempts to unconsume. Would throw a "No buffer left to unconsume"
+        String triggeringSnippet = "<title>One <span>Two";
+        char[] padding = new char[CharacterReader.readAheadLimit - triggeringSnippet.length() + 8]; // The "<span" part must be just at the limit. The "containsIgnoreCase" scan does a bufferUp, losing the unconsume
+        Arrays.fill(padding, ' ');
+        String paddedSnippet = String.valueOf(padding) + triggeringSnippet;
+        ParseErrorList errorList = ParseErrorList.tracking(1);
+        Parser.parseFragment(paddedSnippet, null, "", errorList);
+        // just asserting we don't get a WTF on unconsume
+    }
+
     @Test
     public void testOpeningAngleBracketInsteadOfAttribute() {
         String triggeringSnippet = "<html <";
diff --git a/src/test/java/org/jsoup/parser/XmlTreeBuilderTest.java b/src/test/java/org/jsoup/parser/XmlTreeBuilderTest.java
index b51755db..8fb0fbd8 100644
--- a/src/test/java/org/jsoup/parser/XmlTreeBuilderTest.java
+++ b/src/test/java/org/jsoup/parser/XmlTreeBuilderTest.java
@@ -152,6 +152,16 @@ public class XmlTreeBuilderTest {
         assertEquals("<?xml version=\"1\" encoding=\"UTF-8\" something=\"else\"?>", decl.outerHtml());
     }
 
+    @Test
+    public void testParseDeclarationWithoutAttributes() {
+        String xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<?myProcessingInstruction My Processing instruction.?>";
+        Document doc = Jsoup.parse(xml, "", Parser.xmlParser());
+        XmlDeclaration decl = (XmlDeclaration) doc.childNode(2);
+        assertEquals("myProcessingInstruction", decl.name());
+        assertTrue(decl.hasAttr("My"));
+        assertEquals("<?myProcessingInstruction My Processing instruction.?>", decl.outerHtml());
+    }
+
     @Test
     public void caseSensitiveDeclaration() {
         String xml = "<?XML version='1' encoding='UTF-8' something='else'?>";
@@ -262,4 +272,22 @@ public class XmlTreeBuilderTest {
         assertNull(treeBuilder.tokeniser);
     }
 
+    @Test public void xmlParserEnablesXmlOutputAndEscapes() {
+        // Test that when using the XML parser, the output mode and escape mode default to XHTML entities
+        // https://github.com/jhy/jsoup/issues/1420
+        Document doc = Jsoup.parse("<p one='&lt;two&gt;&copy'>Three</p>", "", Parser.xmlParser());
+        assertEquals(doc.outputSettings().syntax(), Syntax.xml);
+        assertEquals(doc.outputSettings().escapeMode(), Entities.EscapeMode.xhtml);
+        assertEquals("<p one=\"&lt;two>©\">Three</p>", doc.html()); // only the < should be escaped
+    }
+
+    @Test public void xmlSyntaxEscapesLtInAttributes() {
+        // Regardless of the entity escape mode, make sure < is escaped in attributes when in XML
+        Document doc = Jsoup.parse("<p one='&lt;two&gt;&copy'>Three</p>", "", Parser.xmlParser());
+        doc.outputSettings().escapeMode(Entities.EscapeMode.extended);
+        doc.outputSettings().charset("ascii"); // to make sure &copy; is output
+        assertEquals(doc.outputSettings().syntax(), Syntax.xml);
+        assertEquals("<p one=\"&lt;two>&copy;\">Three</p>", doc.html());
+    }
+
 }
diff --git a/src/test/java/org/jsoup/select/QueryParserTest.java b/src/test/java/org/jsoup/select/QueryParserTest.java
index 34ee8a9f..8c4db234 100644
--- a/src/test/java/org/jsoup/select/QueryParserTest.java
+++ b/src/test/java/org/jsoup/select/QueryParserTest.java
@@ -1,5 +1,7 @@
 package org.jsoup.select;
 
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.*;
@@ -10,6 +12,19 @@ import static org.junit.jupiter.api.Assertions.*;
  * @author Jonathan Hedley
  */
 public class QueryParserTest {
+
+    @Test public void testConsumeSubQuery() {
+        Document doc = Jsoup.parse("<html><head>h</head><body>" +
+                "<li><strong>l1</strong></li>" +
+                "<a><li><strong>l2</strong></li></a>" +
+                "<p><strong>yes</strong></p>" +
+                "</body></html>");
+        assertEquals("l1 l2 yes", doc.body().select(">p>strong,>*>li>strong").text());
+        assertEquals("l2 yes", doc.select("body>p>strong,body>*>li>strong").text());
+        assertEquals("yes", doc.select(">body>*>li>strong,>body>p>strong").text());
+        assertEquals("l2", doc.select(">body>p>strong,>body>*>li>strong").text());
+    }
+
     @Test public void testOrGetsCorrectPrecedence() {
         // tests that a selector "a b, c d, e f" evals to (a AND b) OR (c AND d) OR (e AND f)"
         // top level or, three child ands
diff --git a/src/test/java/org/jsoup/select/SelectorTest.java b/src/test/java/org/jsoup/select/SelectorTest.java
index ac46ee7f..def16ed2 100644
--- a/src/test/java/org/jsoup/select/SelectorTest.java
+++ b/src/test/java/org/jsoup/select/SelectorTest.java
@@ -996,4 +996,20 @@ public class SelectorTest {
         assertEquals("One Two", spans.get(0).text());
         assertEquals("Three Four", spans.get(1).text());
     }
+
+    @Test
+    public void wildcardNamespaceMatchesNoNamespace() {
+        // https://github.com/jhy/jsoup/issues/1565
+        String xml = "<package><meta>One</meta><opf:meta>Two</opf:meta></package>";
+        Document doc = Jsoup.parse(xml, "", Parser.xmlParser());
+
+        Elements metaEls = doc.select("meta");
+        assertEquals(1, metaEls.size());
+        assertEquals("One", metaEls.get(0).text());
+
+        Elements nsEls = doc.select("*|meta");
+        assertEquals(2, nsEls.size());
+        assertEquals("One", nsEls.get(0).text());
+        assertEquals("Two", nsEls.get(1).text());
+    }
 }