diff --git a/jetty-server/src/main/config/etc/jetty.xml b/jetty-server/src/main/config/etc/jetty.xml
index b2d16df274c..c40f51a8920 100644
--- a/jetty-server/src/main/config/etc/jetty.xml
+++ b/jetty-server/src/main/config/etc/jetty.xml
@@ -75,6 +75,7 @@
+
diff --git a/jetty-server/src/main/config/modules/server.mod b/jetty-server/src/main/config/modules/server.mod
index cc4ee187cca..5650b1248dc 100644
--- a/jetty-server/src/main/config/modules/server.mod
+++ b/jetty-server/src/main/config/modules/server.mod
@@ -82,6 +82,9 @@ etc/jetty.xml
# jetty.httpConfig.responseCookieCompliance=RFC6265
# end::documentation-server-compliance[]
+## multipart/form-data compliance mode of: LEGACY(slow), RFC7578(fast)
+# jetty.httpConfig.multiPartFormDataCompliance=RFC7578
+
# tag::documentation-server-config[]
### Server configuration
## Whether ctrl+c on the console gracefully stops the Jetty server
diff --git a/jetty-server/src/main/java/org/eclipse/jetty/server/HttpConfiguration.java b/jetty-server/src/main/java/org/eclipse/jetty/server/HttpConfiguration.java
index a4d7b2c0f89..c54135de5be 100644
--- a/jetty-server/src/main/java/org/eclipse/jetty/server/HttpConfiguration.java
+++ b/jetty-server/src/main/java/org/eclipse/jetty/server/HttpConfiguration.java
@@ -75,6 +75,7 @@ public class HttpConfiguration implements Dumpable
private UriCompliance _uriCompliance = UriCompliance.DEFAULT;
private CookieCompliance _requestCookieCompliance = CookieCompliance.RFC6265;
private CookieCompliance _responseCookieCompliance = CookieCompliance.RFC6265;
+ private MultiPartFormDataCompliance _multiPartCompliance = MultiPartFormDataCompliance.RFC7578;
private boolean _notifyRemoteAsyncErrors = true;
private boolean _relativeRedirectAllowed;
private HostPort _serverAuthority;
@@ -625,6 +626,21 @@ public class HttpConfiguration implements Dumpable
_responseCookieCompliance = cookieCompliance == null ? CookieCompliance.RFC6265 : cookieCompliance;
}
+ /**
+ * Sets the compliance level for multipart/form-data handling.
+ *
+ * @param multiPartCompliance The multipart/form-data compliance level.
+ */
+ public void setMultiPartFormDataCompliance(MultiPartFormDataCompliance multiPartCompliance)
+ {
+ _multiPartCompliance = multiPartCompliance == null ? MultiPartFormDataCompliance.RFC7578 : multiPartCompliance;
+ }
+
+ public MultiPartFormDataCompliance getMultipartFormDataCompliance()
+ {
+ return _multiPartCompliance;
+ }
+
/**
* @param notifyRemoteAsyncErrors whether remote errors, when detected, are notified to async applications
*/
diff --git a/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormDataCompliance.java b/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormDataCompliance.java
new file mode 100644
index 00000000000..5dd1126b084
--- /dev/null
+++ b/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormDataCompliance.java
@@ -0,0 +1,34 @@
+//
+// ========================================================================
+// Copyright (c) 1995-2022 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.server;
+
+/**
+ * The compliance level for parsing multiPart/form-data
+ */
+public enum MultiPartFormDataCompliance
+{
+ /**
+ * Legacy multiPart/form-data
parsing which is slow but forgiving.
+ * It will accept non-compliant preambles and inconsistent line termination.
+ *
+ * @see org.eclipse.jetty.server.MultiPartInputStreamParser
+ */
+ LEGACY,
+ /**
+ * RFC7578 compliant parsing that is a fast but strict parser.
+ *
+ * @see org.eclipse.jetty.server.MultiPartFormInputStream
+ */
+ RFC7578
+}
diff --git a/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormInputStream.java b/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormInputStream.java
index 763ee57036b..a5c7215c862 100644
--- a/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormInputStream.java
+++ b/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartFormInputStream.java
@@ -37,6 +37,7 @@ import javax.servlet.MultipartConfigElement;
import javax.servlet.ServletInputStream;
import javax.servlet.http.Part;
+import org.eclipse.jetty.server.MultiParts.NonCompliance;
import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.ByteArrayOutputStream2;
import org.eclipse.jetty.util.MultiException;
@@ -104,23 +105,6 @@ public class MultiPartFormInputStream
private volatile int _bufferSize = 16 * 1024;
private State state = State.UNPARSED;
- public enum NonCompliance
- {
- TRANSFER_ENCODING("https://tools.ietf.org/html/rfc7578#section-4.7");
-
- final String _rfcRef;
-
- NonCompliance(String rfcRef)
- {
- _rfcRef = rfcRef;
- }
-
- public String getURL()
- {
- return _rfcRef;
- }
- }
-
/**
* @return an EnumSet of non compliances with the RFC that were accepted by this parser
*/
diff --git a/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartInputStreamParser.java b/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartInputStreamParser.java
new file mode 100644
index 00000000000..a5331f0bc72
--- /dev/null
+++ b/jetty-server/src/main/java/org/eclipse/jetty/server/MultiPartInputStreamParser.java
@@ -0,0 +1,947 @@
+//
+// ========================================================================
+// Copyright (c) 1995-2022 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.server;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Locale;
+import javax.servlet.MultipartConfigElement;
+import javax.servlet.ServletInputStream;
+import javax.servlet.http.Part;
+
+import org.eclipse.jetty.server.MultiParts.NonCompliance;
+import org.eclipse.jetty.util.ByteArrayOutputStream2;
+import org.eclipse.jetty.util.LazyList;
+import org.eclipse.jetty.util.MultiException;
+import org.eclipse.jetty.util.MultiMap;
+import org.eclipse.jetty.util.QuotedStringTokenizer;
+import org.eclipse.jetty.util.ReadLineInputStream;
+import org.eclipse.jetty.util.ReadLineInputStream.Termination;
+import org.eclipse.jetty.util.log.Log;
+import org.eclipse.jetty.util.log.Logger;
+
+/**
+ * MultiPartInputStream
+ *
+ * Handle a MultiPart Mime input stream, breaking it up on the boundary into files and strings.
+ *
+ * Non Compliance warnings are documented by the method {@link #getNonComplianceWarnings()}
+ *
+ * @deprecated Replaced by org.eclipse.jetty.http.MultiPartFormInputStream
+ * The code for MultiPartInputStream is slower than its replacement MultiPartFormInputStream. However
+ * this class accepts formats non compliant the RFC that the new MultiPartFormInputStream does not accept.
+ */
+@Deprecated
+public class MultiPartInputStreamParser
+{
+ private static final Logger LOG = Log.getLogger(MultiPartInputStreamParser.class);
+ public static final MultipartConfigElement __DEFAULT_MULTIPART_CONFIG = new MultipartConfigElement(System.getProperty("java.io.tmpdir"));
+ public static final MultiMap EMPTY_MAP = new MultiMap(Collections.emptyMap());
+ protected InputStream _in;
+ protected MultipartConfigElement _config;
+ protected String _contentType;
+ protected MultiMap _parts;
+ protected Exception _err;
+ protected File _tmpDir;
+ protected File _contextTmpDir;
+ protected boolean _writeFilesWithFilenames;
+ protected boolean _parsed;
+
+ private final EnumSet nonComplianceWarnings = EnumSet.noneOf(NonCompliance.class);
+
+ /**
+ * @return an EnumSet of non compliances with the RFC that were accepted by this parser
+ */
+ public EnumSet getNonComplianceWarnings()
+ {
+ return nonComplianceWarnings;
+ }
+
+ public class MultiPart implements Part
+ {
+ protected String _name;
+ protected String _filename;
+ protected File _file;
+ protected OutputStream _out;
+ protected ByteArrayOutputStream2 _bout;
+ protected String _contentType;
+ protected MultiMap _headers;
+ protected long _size = 0;
+ protected boolean _temporary = true;
+
+ public MultiPart(String name, String filename)
+ throws IOException
+ {
+ _name = name;
+ _filename = filename;
+ }
+
+ @Override
+ public String toString()
+ {
+ return String.format("Part{n=%s,fn=%s,ct=%s,s=%d,t=%b,f=%s}", _name, _filename, _contentType, _size, _temporary, _file);
+ }
+
+ protected void setContentType(String contentType)
+ {
+ _contentType = contentType;
+ }
+
+ protected void open()
+ throws IOException
+ {
+ //We will either be writing to a file, if it has a filename on the content-disposition
+ //and otherwise a byte-array-input-stream, OR if we exceed the getFileSizeThreshold, we
+ //will need to change to write to a file.
+ if (isWriteFilesWithFilenames() && _filename != null && _filename.trim().length() > 0)
+ {
+ createFile();
+ }
+ else
+ {
+ //Write to a buffer in memory until we discover we've exceed the
+ //MultipartConfig fileSizeThreshold
+ _out = _bout = new ByteArrayOutputStream2();
+ }
+ }
+
+ protected void close()
+ throws IOException
+ {
+ _out.close();
+ }
+
+ protected void write(int b)
+ throws IOException
+ {
+ if (MultiPartInputStreamParser.this._config.getMaxFileSize() > 0 && _size + 1 > MultiPartInputStreamParser.this._config.getMaxFileSize())
+ throw new IllegalStateException("Multipart Mime part " + _name + " exceeds max filesize");
+
+ if (MultiPartInputStreamParser.this._config.getFileSizeThreshold() > 0 && _size + 1 > MultiPartInputStreamParser.this._config.getFileSizeThreshold() && _file == null)
+ createFile();
+
+ _out.write(b);
+ _size++;
+ }
+
+ protected void write(byte[] bytes, int offset, int length)
+ throws IOException
+ {
+ if (MultiPartInputStreamParser.this._config.getMaxFileSize() > 0 && _size + length > MultiPartInputStreamParser.this._config.getMaxFileSize())
+ throw new IllegalStateException("Multipart Mime part " + _name + " exceeds max filesize");
+
+ if (MultiPartInputStreamParser.this._config.getFileSizeThreshold() > 0 && _size + length > MultiPartInputStreamParser.this._config.getFileSizeThreshold() && _file == null)
+ createFile();
+
+ _out.write(bytes, offset, length);
+ _size += length;
+ }
+
+ protected void createFile()
+ throws IOException
+ {
+ Path parent = MultiPartInputStreamParser.this._tmpDir.toPath();
+ Path tempFile = Files.createTempFile(parent, "MultiPart", "");
+ _file = tempFile.toFile();
+
+ OutputStream fos = Files.newOutputStream(tempFile, StandardOpenOption.WRITE);
+ BufferedOutputStream bos = new BufferedOutputStream(fos);
+
+ if (_size > 0 && _out != null)
+ {
+ //already written some bytes, so need to copy them into the file
+ _out.flush();
+ _bout.writeTo(bos);
+ _out.close();
+ }
+ _bout = null;
+ _out = bos;
+ }
+
+ protected void setHeaders(MultiMap headers)
+ {
+ _headers = headers;
+ }
+
+ /**
+ * @see Part#getContentType()
+ */
+ @Override
+ public String getContentType()
+ {
+ return _contentType;
+ }
+
+ /**
+ * @see Part#getHeader(String)
+ */
+ @Override
+ public String getHeader(String name)
+ {
+ if (name == null)
+ return null;
+ return _headers.getValue(name.toLowerCase(Locale.ENGLISH), 0);
+ }
+
+ /**
+ * @see Part#getHeaderNames()
+ */
+ @Override
+ public Collection getHeaderNames()
+ {
+ return _headers.keySet();
+ }
+
+ /**
+ * @see Part#getHeaders(String)
+ */
+ @Override
+ public Collection getHeaders(String name)
+ {
+ return _headers.getValues(name);
+ }
+
+ /**
+ * @see Part#getInputStream()
+ */
+ @Override
+ public InputStream getInputStream() throws IOException
+ {
+ if (_file != null)
+ {
+ //written to a file, whether temporary or not
+ return new BufferedInputStream(new FileInputStream(_file));
+ }
+ else
+ {
+ //part content is in memory
+ return new ByteArrayInputStream(_bout.getBuf(), 0, _bout.size());
+ }
+ }
+
+ /**
+ * @see Part#getSubmittedFileName()
+ */
+ @Override
+ public String getSubmittedFileName()
+ {
+ return getContentDispositionFilename();
+ }
+
+ public byte[] getBytes()
+ {
+ if (_bout != null)
+ return _bout.toByteArray();
+ return null;
+ }
+
+ /**
+ * @see Part#getName()
+ */
+ @Override
+ public String getName()
+ {
+ return _name;
+ }
+
+ /**
+ * @see Part#getSize()
+ */
+ @Override
+ public long getSize()
+ {
+ return _size;
+ }
+
+ /**
+ * @see Part#write(String)
+ */
+ @Override
+ public void write(String fileName) throws IOException
+ {
+ if (_file == null)
+ {
+ _temporary = false;
+
+ //part data is only in the ByteArrayOutputStream and never been written to disk
+ _file = new File(_tmpDir, fileName);
+
+ BufferedOutputStream bos = null;
+ try
+ {
+ bos = new BufferedOutputStream(new FileOutputStream(_file));
+ _bout.writeTo(bos);
+ bos.flush();
+ }
+ finally
+ {
+ if (bos != null)
+ bos.close();
+ _bout = null;
+ }
+ }
+ else
+ {
+ //the part data is already written to a temporary file, just rename it
+ _temporary = false;
+
+ Path src = _file.toPath();
+ Path target = src.resolveSibling(fileName);
+ Files.move(src, target, StandardCopyOption.REPLACE_EXISTING);
+ _file = target.toFile();
+ }
+ }
+
+ /**
+ * Remove the file, whether or not Part.write() was called on it
+ * (ie no longer temporary)
+ *
+ * @see Part#delete()
+ */
+ @Override
+ public void delete() throws IOException
+ {
+ if (_file != null && _file.exists())
+ _file.delete();
+ }
+
+ /**
+ * Only remove tmp files.
+ *
+ * @throws IOException if unable to delete the file
+ */
+ public void cleanUp() throws IOException
+ {
+ if (_temporary && _file != null && _file.exists())
+ _file.delete();
+ }
+
+ /**
+ * Get the file
+ *
+ * @return the file, if any, the data has been written to.
+ */
+ public File getFile()
+ {
+ return _file;
+ }
+
+ /**
+ * Get the filename from the content-disposition.
+ *
+ * @return null or the filename
+ */
+ public String getContentDispositionFilename()
+ {
+ return _filename;
+ }
+ }
+
+ /**
+ * @param in Request input stream
+ * @param contentType Content-Type header
+ * @param config MultipartConfigElement
+ * @param contextTmpDir javax.servlet.context.tempdir
+ */
+ public MultiPartInputStreamParser(InputStream in, String contentType, MultipartConfigElement config, File contextTmpDir)
+ {
+ _contentType = contentType;
+ _config = config;
+ _contextTmpDir = contextTmpDir;
+ if (_contextTmpDir == null)
+ _contextTmpDir = new File(System.getProperty("java.io.tmpdir"));
+
+ if (_config == null)
+ _config = new MultipartConfigElement(_contextTmpDir.getAbsolutePath());
+
+ if (in instanceof ServletInputStream)
+ {
+ if (((ServletInputStream)in).isFinished())
+ {
+ _parts = EMPTY_MAP;
+ _parsed = true;
+ return;
+ }
+ }
+ _in = new ReadLineInputStream(in);
+ }
+
+ /**
+ * Get the already parsed parts.
+ *
+ * @return the parts that were parsed
+ */
+ public Collection getParsedParts()
+ {
+ if (_parts == null)
+ return Collections.emptyList();
+
+ Collection> values = _parts.values();
+ List parts = new ArrayList<>();
+ for (List o : values)
+ {
+ List asList = LazyList.getList(o, false);
+ parts.addAll(asList);
+ }
+ return parts;
+ }
+
+ /**
+ * Delete any tmp storage for parts, and clear out the parts list.
+ */
+ public void deleteParts()
+ {
+ if (!_parsed)
+ return;
+
+ Collection parts = getParsedParts();
+ MultiException err = new MultiException();
+ for (Part p : parts)
+ {
+ try
+ {
+ ((MultiPart)p).cleanUp();
+ }
+ catch (Exception e)
+ {
+ err.add(e);
+ }
+ }
+ _parts.clear();
+
+ err.ifExceptionThrowRuntime();
+ }
+
+ /**
+ * Parse, if necessary, the multipart data and return the list of Parts.
+ *
+ * @return the parts
+ * @throws IOException if unable to get the parts
+ */
+ public Collection getParts()
+ throws IOException
+ {
+ if (!_parsed)
+ parse();
+ throwIfError();
+
+ Collection> values = _parts.values();
+ List parts = new ArrayList<>();
+ for (List o : values)
+ {
+ List asList = LazyList.getList(o, false);
+ parts.addAll(asList);
+ }
+ return parts;
+ }
+
+ /**
+ * Get the named Part.
+ *
+ * @param name the part name
+ * @return the parts
+ * @throws IOException if unable to get the part
+ */
+ public Part getPart(String name)
+ throws IOException
+ {
+ if (!_parsed)
+ parse();
+ throwIfError();
+ return _parts.getValue(name, 0);
+ }
+
+ /**
+ * Throws an exception if one has been latched.
+ *
+ * @throws IOException the exception (if present)
+ */
+ protected void throwIfError()
+ throws IOException
+ {
+ if (_err != null)
+ {
+ if (_err instanceof IOException)
+ throw (IOException)_err;
+ if (_err instanceof IllegalStateException)
+ throw (IllegalStateException)_err;
+ throw new IllegalStateException(_err);
+ }
+ }
+
+ /**
+ * Parse, if necessary, the multipart stream.
+ */
+ protected void parse()
+ {
+ //have we already parsed the input?
+ if (_parsed)
+ return;
+ _parsed = true;
+
+ //initialize
+ long total = 0; //keep running total of size of bytes read from input and throw an exception if exceeds MultipartConfigElement._maxRequestSize
+ _parts = new MultiMap<>();
+
+ //if its not a multipart request, don't parse it
+ if (_contentType == null || !_contentType.startsWith("multipart/form-data"))
+ return;
+
+ try
+ {
+ //sort out the location to which to write the files
+
+ if (_config.getLocation() == null)
+ _tmpDir = _contextTmpDir;
+ else if ("".equals(_config.getLocation()))
+ _tmpDir = _contextTmpDir;
+ else
+ {
+ File f = new File(_config.getLocation());
+ if (f.isAbsolute())
+ _tmpDir = f;
+ else
+ _tmpDir = new File(_contextTmpDir, _config.getLocation());
+ }
+
+ if (!_tmpDir.exists())
+ _tmpDir.mkdirs();
+
+ String contentTypeBoundary = "";
+ int bstart = _contentType.indexOf("boundary=");
+ if (bstart >= 0)
+ {
+ int bend = _contentType.indexOf(";", bstart);
+ bend = (bend < 0 ? _contentType.length() : bend);
+ contentTypeBoundary = QuotedStringTokenizer.unquote(value(_contentType.substring(bstart, bend)).trim());
+ }
+
+ String boundary = "--" + contentTypeBoundary;
+ String lastBoundary = boundary + "--";
+ byte[] byteBoundary = lastBoundary.getBytes(StandardCharsets.ISO_8859_1);
+
+ // Get first boundary
+ String line = null;
+ try
+ {
+ line = ((ReadLineInputStream)_in).readLine();
+ }
+ catch (IOException e)
+ {
+ LOG.warn("Badly formatted multipart request");
+ throw e;
+ }
+
+ if (line == null)
+ throw new IOException("Missing content for multipart request");
+
+ boolean badFormatLogged = false;
+
+ String untrimmed = line;
+ line = line.trim();
+ while (line != null && !line.equals(boundary) && !line.equals(lastBoundary))
+ {
+ if (!badFormatLogged)
+ {
+ LOG.warn("Badly formatted multipart request");
+ badFormatLogged = true;
+ }
+ line = ((ReadLineInputStream)_in).readLine();
+ untrimmed = line;
+ if (line != null)
+ line = line.trim();
+ }
+
+ if (line == null || line.length() == 0)
+ throw new IOException("Missing initial multi part boundary");
+
+ // Empty multipart.
+ if (line.equals(lastBoundary))
+ return;
+
+ // check compliance of preamble
+ if (Character.isWhitespace(untrimmed.charAt(0)))
+ nonComplianceWarnings.add(NonCompliance.NO_CRLF_AFTER_PREAMBLE);
+
+ // Read each part
+ boolean lastPart = false;
+
+ outer:
+ while (!lastPart)
+ {
+ String contentDisposition = null;
+ String contentType = null;
+ String contentTransferEncoding = null;
+
+ MultiMap headers = new MultiMap<>();
+ while (true)
+ {
+ line = ((ReadLineInputStream)_in).readLine();
+
+ //No more input
+ if (line == null)
+ break outer;
+
+ //end of headers:
+ if ("".equals(line))
+ break;
+
+ total += line.length();
+ if (_config.getMaxRequestSize() > 0 && total > _config.getMaxRequestSize())
+ throw new IllegalStateException("Request exceeds maxRequestSize (" + _config.getMaxRequestSize() + ")");
+
+ //get content-disposition and content-type
+ int c = line.indexOf(':');
+ if (c > 0)
+ {
+ String key = line.substring(0, c).trim().toLowerCase(Locale.ENGLISH);
+ String value = line.substring(c + 1).trim();
+ headers.put(key, value);
+ if (key.equalsIgnoreCase("content-disposition"))
+ contentDisposition = value;
+ if (key.equalsIgnoreCase("content-type"))
+ contentType = value;
+ if (key.equals("content-transfer-encoding"))
+ contentTransferEncoding = value;
+ }
+ }
+
+ // Extract content-disposition
+ boolean formData = false;
+ if (contentDisposition == null)
+ {
+ throw new IOException("Missing content-disposition");
+ }
+
+ QuotedStringTokenizer tok = new QuotedStringTokenizer(contentDisposition, ";", false, true);
+ String name = null;
+ String filename = null;
+ while (tok.hasMoreTokens())
+ {
+ String t = tok.nextToken().trim();
+ String tl = t.toLowerCase(Locale.ENGLISH);
+ if (tl.startsWith("form-data"))
+ formData = true;
+ else if (tl.startsWith("name="))
+ name = value(t);
+ else if (tl.startsWith("filename="))
+ filename = filenameValue(t);
+ }
+
+ // Check disposition
+ if (!formData)
+ {
+ continue;
+ }
+ //It is valid for reset and submit buttons to have an empty name.
+ //If no name is supplied, the browser skips sending the info for that field.
+ //However, if you supply the empty string as the name, the browser sends the
+ //field, with name as the empty string. So, only continue this loop if we
+ //have not yet seen a name field.
+ if (name == null)
+ {
+ continue;
+ }
+
+ //Have a new Part
+ MultiPart part = new MultiPart(name, filename);
+ part.setHeaders(headers);
+ part.setContentType(contentType);
+ _parts.add(name, part);
+ part.open();
+
+ InputStream partInput = null;
+ if ("base64".equalsIgnoreCase(contentTransferEncoding))
+ {
+ nonComplianceWarnings.add(NonCompliance.BASE64_TRANSFER_ENCODING);
+ partInput = new Base64InputStream((ReadLineInputStream)_in);
+ }
+ else if ("quoted-printable".equalsIgnoreCase(contentTransferEncoding))
+ {
+ nonComplianceWarnings.add(NonCompliance.QUOTED_PRINTABLE_TRANSFER_ENCODING);
+ partInput = new FilterInputStream(_in)
+ {
+ @Override
+ public int read() throws IOException
+ {
+ int c = in.read();
+ if (c >= 0 && c == '=')
+ {
+ int hi = in.read();
+ int lo = in.read();
+ if (hi < 0 || lo < 0)
+ {
+ throw new IOException("Unexpected end to quoted-printable byte");
+ }
+ char[] chars = new char[]{(char)hi, (char)lo};
+ c = Integer.parseInt(new String(chars), 16);
+ }
+ return c;
+ }
+ };
+ }
+ else
+ partInput = _in;
+
+ try
+ {
+ int state = -2;
+ int c;
+ boolean cr = false;
+ boolean lf = false;
+
+ // loop for all lines
+ while (true)
+ {
+ int b = 0;
+ while ((c = (state != -2) ? state : partInput.read()) != -1)
+ {
+ total++;
+ if (_config.getMaxRequestSize() > 0 && total > _config.getMaxRequestSize())
+ throw new IllegalStateException("Request exceeds maxRequestSize (" + _config.getMaxRequestSize() + ")");
+
+ state = -2;
+
+ // look for CR and/or LF
+ if (c == 13 || c == 10)
+ {
+ if (c == 13)
+ {
+ partInput.mark(1);
+ int tmp = partInput.read();
+ if (tmp != 10)
+ partInput.reset();
+ else
+ state = tmp;
+ }
+ break;
+ }
+
+ // Look for boundary
+ if (b >= 0 && b < byteBoundary.length && c == byteBoundary[b])
+ {
+ b++;
+ }
+ else
+ {
+ // Got a character not part of the boundary, so we don't have the boundary marker.
+ // Write out as many chars as we matched, then the char we're looking at.
+ if (cr)
+ part.write(13);
+
+ if (lf)
+ part.write(10);
+
+ cr = lf = false;
+ if (b > 0)
+ part.write(byteBoundary, 0, b);
+
+ b = -1;
+ part.write(c);
+ }
+ }
+
+ // Check for incomplete boundary match, writing out the chars we matched along the way
+ if ((b > 0 && b < byteBoundary.length - 2) || (b == byteBoundary.length - 1))
+ {
+ if (cr)
+ part.write(13);
+
+ if (lf)
+ part.write(10);
+
+ cr = lf = false;
+ part.write(byteBoundary, 0, b);
+ b = -1;
+ }
+
+ // Boundary match. If we've run out of input or we matched the entire final boundary marker, then this is the last part.
+ if (b > 0 || c == -1)
+ {
+
+ if (b == byteBoundary.length)
+ lastPart = true;
+ if (state == 10)
+ state = -2;
+ break;
+ }
+
+ // handle CR LF
+ if (cr)
+ part.write(13);
+
+ if (lf)
+ part.write(10);
+
+ cr = (c == 13);
+ lf = (c == 10 || state == 10);
+ if (state == 10)
+ state = -2;
+ }
+ }
+ finally
+ {
+ part.close();
+ }
+ }
+ if (lastPart)
+ {
+ while (line != null)
+ {
+ line = ((ReadLineInputStream)_in).readLine();
+ }
+
+ EnumSet term = ((ReadLineInputStream)_in).getLineTerminations();
+
+ if (term.contains(Termination.CR))
+ nonComplianceWarnings.add(NonCompliance.CR_LINE_TERMINATION);
+ if (term.contains(Termination.LF))
+ nonComplianceWarnings.add(NonCompliance.LF_LINE_TERMINATION);
+ }
+ else
+ throw new IOException("Incomplete parts");
+ }
+ catch (Exception e)
+ {
+ _err = e;
+ }
+ }
+
+ /**
+ * @deprecated no replacement offered.
+ */
+ @Deprecated
+ public void setDeleteOnExit(boolean deleteOnExit)
+ {
+ // does nothing
+ }
+
+ public void setWriteFilesWithFilenames(boolean writeFilesWithFilenames)
+ {
+ _writeFilesWithFilenames = writeFilesWithFilenames;
+ }
+
+ public boolean isWriteFilesWithFilenames()
+ {
+ return _writeFilesWithFilenames;
+ }
+
+ /**
+ * @deprecated no replacement offered.
+ */
+ @Deprecated
+ public boolean isDeleteOnExit()
+ {
+ return false;
+ }
+
+ private String value(String nameEqualsValue)
+ {
+ int idx = nameEqualsValue.indexOf('=');
+ String value = nameEqualsValue.substring(idx + 1).trim();
+ return QuotedStringTokenizer.unquoteOnly(value);
+ }
+
+ private String filenameValue(String nameEqualsValue)
+ {
+ int idx = nameEqualsValue.indexOf('=');
+ String value = nameEqualsValue.substring(idx + 1).trim();
+
+ if (value.matches(".??[a-z,A-Z]\\:\\\\[^\\\\].*"))
+ {
+ //incorrectly escaped IE filenames that have the whole path
+ //we just strip any leading & trailing quotes and leave it as is
+ char first = value.charAt(0);
+ if (first == '"' || first == '\'')
+ value = value.substring(1);
+ char last = value.charAt(value.length() - 1);
+ if (last == '"' || last == '\'')
+ value = value.substring(0, value.length() - 1);
+
+ return value;
+ }
+ else
+ //unquote the string, but allow any backslashes that don't
+ //form a valid escape sequence to remain as many browsers
+ //even on *nix systems will not escape a filename containing
+ //backslashes
+ return QuotedStringTokenizer.unquoteOnly(value, true);
+ }
+
+ // TODO: considers switching to Base64.getMimeDecoder().wrap(InputStream)
+ private static class Base64InputStream extends InputStream
+ {
+ ReadLineInputStream _in;
+ String _line;
+ byte[] _buffer;
+ int _pos;
+ Base64.Decoder base64Decoder = Base64.getDecoder();
+
+ public Base64InputStream(ReadLineInputStream rlis)
+ {
+ _in = rlis;
+ }
+
+ @Override
+ public int read() throws IOException
+ {
+ if (_buffer == null || _pos >= _buffer.length)
+ {
+ //Any CR and LF will be consumed by the readLine() call.
+ //We need to put them back into the bytes returned from this
+ //method because the parsing of the multipart content uses them
+ //as markers to determine when we've reached the end of a part.
+ _line = _in.readLine();
+ if (_line == null)
+ return -1; //nothing left
+ if (_line.startsWith("--"))
+ _buffer = (_line + "\r\n").getBytes(); //boundary marking end of part
+ else if (_line.length() == 0)
+ _buffer = "\r\n".getBytes(); //blank line
+ else
+ {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream((4 * _line.length() / 3) + 2);
+ baos.write(base64Decoder.decode(_line));
+ baos.write(13);
+ baos.write(10);
+ _buffer = baos.toByteArray();
+ }
+
+ _pos = 0;
+ }
+
+ return _buffer[_pos++];
+ }
+ }
+}
diff --git a/jetty-server/src/main/java/org/eclipse/jetty/server/MultiParts.java b/jetty-server/src/main/java/org/eclipse/jetty/server/MultiParts.java
new file mode 100644
index 00000000000..bc83399c243
--- /dev/null
+++ b/jetty-server/src/main/java/org/eclipse/jetty/server/MultiParts.java
@@ -0,0 +1,209 @@
+//
+// ========================================================================
+// Copyright (c) 1995-2022 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.server;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.List;
+import javax.servlet.MultipartConfigElement;
+import javax.servlet.http.Part;
+
+import org.eclipse.jetty.http.HttpCompliance;
+import org.eclipse.jetty.server.handler.ContextHandler;
+import org.eclipse.jetty.server.handler.ContextHandler.Context;
+
+/*
+ * Used to switch between the old and new implementation of MultiPart Form InputStream Parsing.
+ * The new implementation is preferred will be used as default unless specified otherwise constructor.
+ */
+public interface MultiParts extends Closeable
+{
+ enum NonCompliance
+ {
+ CR_LINE_TERMINATION("https://tools.ietf.org/html/rfc2046#section-4.1.1"),
+ LF_LINE_TERMINATION("https://tools.ietf.org/html/rfc2046#section-4.1.1"),
+ NO_CRLF_AFTER_PREAMBLE("https://tools.ietf.org/html/rfc2046#section-5.1.1"),
+ BASE64_TRANSFER_ENCODING("https://tools.ietf.org/html/rfc7578#section-4.7"),
+ QUOTED_PRINTABLE_TRANSFER_ENCODING("https://tools.ietf.org/html/rfc7578#section-4.7"),
+ TRANSFER_ENCODING("https://tools.ietf.org/html/rfc7578#section-4.7");
+
+ final String _rfcRef;
+
+ NonCompliance(String rfcRef)
+ {
+ _rfcRef = rfcRef;
+ }
+
+ public String getURL()
+ {
+ return _rfcRef;
+ }
+ }
+
+ Collection getParts() throws IOException;
+
+ Part getPart(String name) throws IOException;
+
+ boolean isEmpty();
+
+ Context getContext();
+
+ EnumSet getNonComplianceWarnings();
+
+ class MultiPartsHttpParser implements MultiParts
+ {
+ private final MultiPartFormInputStream _httpParser;
+ private final ContextHandler.Context _context;
+ private final Request _request;
+
+ public MultiPartsHttpParser(InputStream in, String contentType, MultipartConfigElement config, File contextTmpDir, Request request) throws IOException
+ {
+ _httpParser = new MultiPartFormInputStream(in, contentType, config, contextTmpDir);
+ _context = request.getContext();
+ _request = request;
+ }
+
+ @Override
+ public Collection getParts() throws IOException
+ {
+ Collection parts = _httpParser.getParts();
+ setNonComplianceViolationsOnRequest();
+ return parts;
+ }
+
+ @Override
+ public Part getPart(String name) throws IOException
+ {
+ Part part = _httpParser.getPart(name);
+ setNonComplianceViolationsOnRequest();
+ return part;
+ }
+
+ @Override
+ public void close()
+ {
+ _httpParser.deleteParts();
+ }
+
+ @Override
+ public boolean isEmpty()
+ {
+ return _httpParser.isEmpty();
+ }
+
+ @Override
+ public Context getContext()
+ {
+ return _context;
+ }
+
+ @Override
+ public EnumSet getNonComplianceWarnings()
+ {
+ return _httpParser.getNonComplianceWarnings();
+ }
+
+ private void setNonComplianceViolationsOnRequest()
+ {
+ @SuppressWarnings("unchecked")
+ List violations = (List)_request.getAttribute(HttpCompliance.VIOLATIONS_ATTR);
+ if (violations != null)
+ return;
+
+ EnumSet nonComplianceWarnings = _httpParser.getNonComplianceWarnings();
+ violations = new ArrayList<>();
+ for (NonCompliance nc : nonComplianceWarnings)
+ {
+ violations.add(nc.name() + ": " + nc.getURL());
+ }
+ _request.setAttribute(HttpCompliance.VIOLATIONS_ATTR, violations);
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ class MultiPartsUtilParser implements MultiParts
+ {
+ private final MultiPartInputStreamParser _utilParser;
+ private final ContextHandler.Context _context;
+ private final Request _request;
+
+ public MultiPartsUtilParser(InputStream in, String contentType, MultipartConfigElement config, File contextTmpDir, Request request) throws IOException
+ {
+ _utilParser = new MultiPartInputStreamParser(in, contentType, config, contextTmpDir);
+ _context = request.getContext();
+ _request = request;
+ }
+
+ @Override
+ public Collection getParts() throws IOException
+ {
+ Collection parts = _utilParser.getParts();
+ setNonComplianceViolationsOnRequest();
+ return parts;
+ }
+
+ @Override
+ public Part getPart(String name) throws IOException
+ {
+ Part part = _utilParser.getPart(name);
+ setNonComplianceViolationsOnRequest();
+ return part;
+ }
+
+ @Override
+ public void close()
+ {
+ _utilParser.deleteParts();
+ }
+
+ @Override
+ public boolean isEmpty()
+ {
+ return _utilParser.getParsedParts().isEmpty();
+ }
+
+ @Override
+ public Context getContext()
+ {
+ return _context;
+ }
+
+ @Override
+ public EnumSet getNonComplianceWarnings()
+ {
+ return _utilParser.getNonComplianceWarnings();
+ }
+
+ private void setNonComplianceViolationsOnRequest()
+ {
+ @SuppressWarnings("unchecked")
+ List violations = (List)_request.getAttribute(HttpCompliance.VIOLATIONS_ATTR);
+ if (violations != null)
+ return;
+
+ EnumSet nonComplianceWarnings = _utilParser.getNonComplianceWarnings();
+ violations = new ArrayList<>();
+ for (NonCompliance nc : nonComplianceWarnings)
+ {
+ violations.add(nc.name() + ": " + nc.getURL());
+ }
+ _request.setAttribute(HttpCompliance.VIOLATIONS_ATTR, violations);
+ }
+ }
+}
diff --git a/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java b/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java
index 126766c4c0c..b4b9b6ae88f 100644
--- a/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java
+++ b/jetty-server/src/main/java/org/eclipse/jetty/server/Request.java
@@ -66,7 +66,6 @@ import javax.servlet.http.WebConnection;
import org.eclipse.jetty.http.BadMessageException;
import org.eclipse.jetty.http.ComplianceViolation;
import org.eclipse.jetty.http.HostPortHttpField;
-import org.eclipse.jetty.http.HttpCompliance;
import org.eclipse.jetty.http.HttpCookie;
import org.eclipse.jetty.http.HttpCookie.SetCookieHttpField;
import org.eclipse.jetty.http.HttpField;
@@ -231,7 +230,7 @@ public class Request implements HttpServletRequest
private HttpSession _session;
private SessionHandler _sessionHandler;
private long _timeStamp;
- private MultiPartFormInputStream _multiParts; //if the request is a multi-part mime
+ private MultiParts _multiParts; //if the request is a multi-part mime
private AsyncContextState _async;
private List _sessions; //list of sessions used during lifetime of request
@@ -1464,7 +1463,7 @@ public class Request implements HttpServletRequest
{
try
{
- _multiParts.deleteParts();
+ _multiParts.close();
}
catch (Throwable e)
{
@@ -2296,7 +2295,6 @@ public class Request implements HttpServletRequest
_multiParts = newMultiParts(config);
Collection parts = _multiParts.getParts();
- setNonComplianceViolationsOnRequest();
String formCharset = null;
Part charsetPart = _multiParts.getPart("_charset_");
@@ -2357,26 +2355,23 @@ public class Request implements HttpServletRequest
return _multiParts.getParts();
}
- private void setNonComplianceViolationsOnRequest()
+ private MultiParts newMultiParts(MultipartConfigElement config) throws IOException
{
- @SuppressWarnings("unchecked")
- List violations = (List)getAttribute(HttpCompliance.VIOLATIONS_ATTR);
- if (violations != null)
- return;
+ MultiPartFormDataCompliance compliance = getHttpChannel().getHttpConfiguration().getMultipartFormDataCompliance();
+ if (LOG.isDebugEnabled())
+ LOG.debug("newMultiParts {} {}", compliance, this);
- EnumSet nonComplianceWarnings = _multiParts.getNonComplianceWarnings();
- violations = new ArrayList<>();
- for (MultiPartFormInputStream.NonCompliance nc : nonComplianceWarnings)
+ switch (compliance)
{
- violations.add(nc.name() + ": " + nc.getURL());
- }
- setAttribute(HttpCompliance.VIOLATIONS_ATTR, violations);
- }
+ case RFC7578:
+ return new MultiParts.MultiPartsHttpParser(getInputStream(), getContentType(), config,
+ (_context != null ? (File)_context.getAttribute("javax.servlet.context.tempdir") : null), this);
- private MultiPartFormInputStream newMultiParts(MultipartConfigElement config) throws IOException
- {
- return new MultiPartFormInputStream(getInputStream(), getContentType(), config,
- (_context != null ? (File)_context.getAttribute("javax.servlet.context.tempdir") : null));
+ case LEGACY:
+ default:
+ return new MultiParts.MultiPartsUtilParser(getInputStream(), getContentType(), config,
+ (_context != null ? (File)_context.getAttribute("javax.servlet.context.tempdir") : null), this);
+ }
}
@Override
diff --git a/jetty-server/src/test/java/org/eclipse/jetty/server/MultiPartFormInputStreamTest.java b/jetty-server/src/test/java/org/eclipse/jetty/server/MultiPartFormInputStreamTest.java
index 4b067c8682e..4d74b56374b 100644
--- a/jetty-server/src/test/java/org/eclipse/jetty/server/MultiPartFormInputStreamTest.java
+++ b/jetty-server/src/test/java/org/eclipse/jetty/server/MultiPartFormInputStreamTest.java
@@ -34,7 +34,7 @@ import javax.servlet.ServletInputStream;
import javax.servlet.http.Part;
import org.eclipse.jetty.server.MultiPartFormInputStream.MultiPart;
-import org.eclipse.jetty.server.MultiPartFormInputStream.NonCompliance;
+import org.eclipse.jetty.server.MultiParts.NonCompliance;
import org.eclipse.jetty.toolchain.test.MavenTestingUtils;
import org.eclipse.jetty.util.BlockingArrayQueue;
import org.eclipse.jetty.util.BufferUtil;
diff --git a/jetty-server/src/test/java/org/eclipse/jetty/server/RequestTest.java b/jetty-server/src/test/java/org/eclipse/jetty/server/RequestTest.java
index 91669cf873f..8413367854f 100644
--- a/jetty-server/src/test/java/org/eclipse/jetty/server/RequestTest.java
+++ b/jetty-server/src/test/java/org/eclipse/jetty/server/RequestTest.java
@@ -530,6 +530,54 @@ public class RequestTest
assertThat("File Count in dir: " + testTmpDir, getFileCount(testTmpDir), is(0L));
}
+ @Test
+ public void testLegacyMultiPart() throws Exception
+ {
+ Path testTmpDir = workDir.getEmptyPathDir();
+
+ // We should have two tmp files after parsing the multipart form.
+ RequestTester tester = (request, response) ->
+ {
+ try (Stream s = Files.list(testTmpDir))
+ {
+ return s.count() == 2;
+ }
+ };
+
+ ContextHandler contextHandler = new ContextHandler();
+ contextHandler.setContextPath("/foo");
+ contextHandler.setResourceBase(".");
+ contextHandler.setHandler(new MultiPartRequestHandler(testTmpDir.toFile(), tester));
+ _server.stop();
+ _server.setHandler(contextHandler);
+ _connector.getBean(HttpConnectionFactory.class).getHttpConfiguration().setMultiPartFormDataCompliance(MultiPartFormDataCompliance.LEGACY);
+ _server.start();
+
+ String multipart = " --AaB03x\r" +
+ "content-disposition: form-data; name=\"field1\"\r" +
+ "\r" +
+ "Joe Blow\r" +
+ "--AaB03x\r" +
+ "content-disposition: form-data; name=\"stuff\"; filename=\"foo.upload\"\r" +
+ "Content-Type: text/plain;charset=ISO-8859-1\r" +
+ "\r" +
+ "000000000000000000000000000000000000000000000000000\r" +
+ "--AaB03x--\r";
+
+ String request = "GET /foo/x.html HTTP/1.1\r\n" +
+ "Host: whatever\r\n" +
+ "Content-Type: multipart/form-data; boundary=\"AaB03x\"\r\n" +
+ "Content-Length: " + multipart.getBytes().length + "\r\n" +
+ "Connection: close\r\n" +
+ "\r\n" +
+ multipart;
+
+ String responses = _connector.getResponse(request);
+ assertThat(responses, Matchers.startsWith("HTTP/1.1 200"));
+ assertThat(responses, Matchers.containsString("Violation: CR_LINE_TERMINATION"));
+ assertThat(responses, Matchers.containsString("Violation: NO_CRLF_AFTER_PREAMBLE"));
+ }
+
@Test
public void testBadMultiPart() throws Exception
{
diff --git a/jetty-util/src/main/java/org/eclipse/jetty/util/ReadLineInputStream.java b/jetty-util/src/main/java/org/eclipse/jetty/util/ReadLineInputStream.java
new file mode 100644
index 00000000000..265422f1d54
--- /dev/null
+++ b/jetty-util/src/main/java/org/eclipse/jetty/util/ReadLineInputStream.java
@@ -0,0 +1,161 @@
+//
+// ========================================================================
+// Copyright (c) 1995-2022 Mort Bay Consulting Pty Ltd and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
+// which is available at https://www.apache.org/licenses/LICENSE-2.0.
+//
+// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
+// ========================================================================
+//
+
+package org.eclipse.jetty.util;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.EnumSet;
+
+/**
+ * ReadLineInputStream
+ *
+ * Read from an input stream, accepting CR/LF, LF or just CR.
+ */
+@Deprecated(forRemoval = true)
+public class ReadLineInputStream extends BufferedInputStream
+{
+ boolean _seenCRLF;
+ boolean _skipLF;
+ private EnumSet _lineTerminations = EnumSet.noneOf(Termination.class);
+
+ public EnumSet getLineTerminations()
+ {
+ return _lineTerminations;
+ }
+
+ public enum Termination
+ {
+ CRLF,
+ LF,
+ CR,
+ EOF
+ }
+
+ public ReadLineInputStream(InputStream in)
+ {
+ super(in);
+ }
+
+ public ReadLineInputStream(InputStream in, int size)
+ {
+ super(in, size);
+ }
+
+ public String readLine() throws IOException
+ {
+ mark(buf.length);
+
+ while (true)
+ {
+ int b = super.read();
+
+ if (markpos < 0)
+ throw new IOException("Buffer size exceeded: no line terminator");
+
+ if (_skipLF && b != '\n')
+ _lineTerminations.add(Termination.CR);
+
+ if (b == -1)
+ {
+ int m = markpos;
+ markpos = -1;
+ if (pos > m)
+ {
+ _lineTerminations.add(Termination.EOF);
+ return new String(buf, m, pos - m, StandardCharsets.UTF_8);
+ }
+ return null;
+ }
+
+ if (b == '\r')
+ {
+ int p = pos;
+
+ // if we have seen CRLF before, hungrily consume LF
+ if (_seenCRLF && pos < count)
+ {
+ if (buf[pos] == '\n')
+ {
+ _lineTerminations.add(Termination.CRLF);
+ pos += 1;
+ }
+ else
+ {
+ _lineTerminations.add(Termination.CR);
+ }
+ }
+ else
+ _skipLF = true;
+
+ int m = markpos;
+ markpos = -1;
+ return new String(buf, m, p - m - 1, StandardCharsets.UTF_8);
+ }
+
+ if (b == '\n')
+ {
+ if (_skipLF)
+ {
+ _skipLF = false;
+ _seenCRLF = true;
+ markpos++;
+ _lineTerminations.add(Termination.CRLF);
+ continue;
+ }
+ int m = markpos;
+ markpos = -1;
+ _lineTerminations.add(Termination.LF);
+ return new String(buf, m, pos - m - 1, StandardCharsets.UTF_8);
+ }
+ }
+ }
+
+ @Override
+ public synchronized int read() throws IOException
+ {
+ int b = super.read();
+ if (_skipLF)
+ {
+ _skipLF = false;
+ if (_seenCRLF && b == '\n')
+ b = super.read();
+ }
+ return b;
+ }
+
+ @Override
+ public synchronized int read(byte[] buf, int off, int len) throws IOException
+ {
+ if (_skipLF && len > 0)
+ {
+ _skipLF = false;
+ if (_seenCRLF)
+ {
+ int b = super.read();
+ if (b == -1)
+ return -1;
+
+ if (b != '\n')
+ {
+ buf[off] = (byte)(0xff & b);
+ return 1 + super.read(buf, off + 1, len - 1);
+ }
+ }
+ }
+
+ return super.read(buf, off, len);
+ }
+}