This is an automated email from the ASF dual-hosted git repository. markt pushed a commit to branch 8.5.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/8.5.x by this push: new 46ede0a Back-port Range, Content-Range and partial PUT improvements 46ede0a is described below commit 46ede0a876475e70ed341987e22e864b222b01bd Author: Mark Thomas <ma...@apache.org> AuthorDate: Tue Jul 2 20:13:05 2019 +0100 Back-port Range, Content-Range and partial PUT improvements --- .../apache/catalina/servlets/DefaultServlet.java | 195 ++++++++++----------- .../tomcat/util/http/parser/ContentRange.java | 108 ++++++++++++ .../apache/tomcat/util/http/parser/HttpParser.java | 37 ++++ .../org/apache/tomcat/util/http/parser/Ranges.java | 124 +++++++++++++ .../catalina/servlets/TestDefaultServletPut.java | 185 +++++++++++++++++++ .../servlets/TestDefaultServletRangeRequests.java | 146 +++++++++++++++ .../apache/catalina/startup/SimpleHttpClient.java | 12 +- webapps/docs/changelog.xml | 21 +++ 8 files changed, 723 insertions(+), 105 deletions(-) diff --git a/java/org/apache/catalina/servlets/DefaultServlet.java b/java/org/apache/catalina/servlets/DefaultServlet.java index 87a9dcb..22f6058 100644 --- a/java/org/apache/catalina/servlets/DefaultServlet.java +++ b/java/org/apache/catalina/servlets/DefaultServlet.java @@ -80,6 +80,8 @@ import org.apache.catalina.util.URLEncoder; import org.apache.catalina.webresources.CachedResource; import org.apache.tomcat.util.buf.B2CConverter; import org.apache.tomcat.util.http.ResponseUtil; +import org.apache.tomcat.util.http.parser.ContentRange; +import org.apache.tomcat.util.http.parser.Ranges; import org.apache.tomcat.util.res.StringManager; import org.apache.tomcat.util.security.Escape; import org.apache.tomcat.util.security.PrivilegedGetTccl; @@ -150,6 +152,8 @@ public class DefaultServlet extends HttpServlet { */ protected static final ArrayList<Range> FULL = new ArrayList<>(); + private static final Range IGNORE = new Range(); + /** * MIME multipart separation string */ @@ -277,6 +281,12 @@ public class DefaultServlet extends HttpServlet { */ protected transient SortManager sortManager; + /** + * Flag that indicates whether partial PUTs are permitted. + */ + private boolean allowPartialPut = true; + + // --------------------------------------------------------- Public Methods /** @@ -377,6 +387,10 @@ public class DefaultServlet extends HttpServlet { sortManager = new SortManager(sortDirectoriesFirst); } } + + if (getServletConfig().getInitParameter("allowPartialPut") != null) { + allowPartialPut = Boolean.parseBoolean(getServletConfig().getInitParameter("allowPartialPut")); + } } private CompressionFormat[] parseCompressionFormats(String precompressed, String gzip) { @@ -608,6 +622,11 @@ public class DefaultServlet extends HttpServlet { Range range = parseContentRange(req, resp); + if (range == null) { + // Processing error. parseContentRange() set the error code + return; + } + InputStream resourceInputStream = null; try { @@ -615,11 +634,11 @@ public class DefaultServlet extends HttpServlet { // resource - create a temp. file on the local filesystem to // perform this operation // Assume just one range is specified for now - if (range != null) { + if (range == IGNORE) { + resourceInputStream = req.getInputStream(); + } else { File contentFile = executePartialPut(req, range, path); resourceInputStream = new FileInputStream(contentFile); - } else { - resourceInputStream = req.getInputStream(); } if (resources.write(path, resourceInputStream, true)) { @@ -915,7 +934,7 @@ public class DefaultServlet extends HttpServlet { } } - ArrayList<Range> ranges = null; + ArrayList<Range> ranges = FULL; long contentLength = -1L; if (resource.isDirectory()) { @@ -941,6 +960,9 @@ public class DefaultServlet extends HttpServlet { // Parse range specifier ranges = parseRange(request, response, resource); + if (ranges == null) { + return; + } // ETag header response.setHeader("ETag", eTag); @@ -1019,12 +1041,7 @@ public class DefaultServlet extends HttpServlet { conversionRequired = false; } - if (resource.isDirectory() || - isError || - ( (ranges == null || ranges.isEmpty()) - && request.getHeader("Range") == null ) || - ranges == FULL ) { - + if (resource.isDirectory() || isError || ranges == FULL ) { // Set the appropriate output headers if (contentType != null) { if (debug > 0) @@ -1381,7 +1398,9 @@ public class DefaultServlet extends HttpServlet { * * @param request The servlet request we are processing * @param response The servlet response we are creating - * @return the range object + * @return the partial content-range, {@code null} if the content-range + * header was invalid or {@code #IGNORE} if there is no header to + * process * @throws IOException an IO error occurred */ protected Range parseContentRange(HttpServletRequest request, @@ -1389,44 +1408,37 @@ public class DefaultServlet extends HttpServlet { throws IOException { // Retrieving the content-range header (if any is specified - String rangeHeader = request.getHeader("Content-Range"); + String contentRangeHeader = request.getHeader("Content-Range"); - if (rangeHeader == null) - return null; + if (contentRangeHeader == null) { + return IGNORE; + } - // bytes is the only range unit supported - if (!rangeHeader.startsWith("bytes")) { + if (!allowPartialPut) { response.sendError(HttpServletResponse.SC_BAD_REQUEST); return null; } - rangeHeader = rangeHeader.substring(6).trim(); - - int dashPos = rangeHeader.indexOf('-'); - int slashPos = rangeHeader.indexOf('/'); + ContentRange contentRange = ContentRange.parse(new StringReader(contentRangeHeader)); - if (dashPos == -1) { + if (contentRange == null) { response.sendError(HttpServletResponse.SC_BAD_REQUEST); return null; } - if (slashPos == -1) { + + // bytes is the only range unit supported + if (!contentRange.getUnits().equals("bytes")) { response.sendError(HttpServletResponse.SC_BAD_REQUEST); return null; } + // TODO: Remove the internal representation and use Ranges + // Convert to internal representation Range range = new Range(); - - try { - range.start = Long.parseLong(rangeHeader.substring(0, dashPos)); - range.end = - Long.parseLong(rangeHeader.substring(dashPos + 1, slashPos)); - range.length = Long.parseLong - (rangeHeader.substring(slashPos + 1, rangeHeader.length())); - } catch (NumberFormatException e) { - response.sendError(HttpServletResponse.SC_BAD_REQUEST); - return null; - } + range.start = contentRange.getStart(); + range.end = contentRange.getEnd(); + range.length = contentRange.getLength(); if (!range.validate()) { response.sendError(HttpServletResponse.SC_BAD_REQUEST); @@ -1434,7 +1446,6 @@ public class DefaultServlet extends HttpServlet { } return range; - } @@ -1444,13 +1455,18 @@ public class DefaultServlet extends HttpServlet { * @param request The servlet request we are processing * @param response The servlet response we are creating * @param resource The resource - * @return a list of ranges + * @return a list of ranges, {@code null} if the range header was invalid or + * {@code #FULL} if the Range header should be ignored. * @throws IOException an IO error occurred */ protected ArrayList<Range> parseRange(HttpServletRequest request, HttpServletResponse response, WebResource resource) throws IOException { + // Range headers are only valid on GET requests. That implies they are + // also valid on HEAD requests. This method is only called by doGet() + // and doHead() so no need to check the request method. + // Checking If-Range String headerValue = request.getHeader("If-Range"); @@ -1467,110 +1483,81 @@ public class DefaultServlet extends HttpServlet { long lastModified = resource.getLastModified(); if (headerValueTime == (-1L)) { - // If the ETag the client gave does not match the entity // etag, then the entire entity is returned. - if (!eTag.equals(headerValue.trim())) + if (!eTag.equals(headerValue.trim())) { return FULL; - + } } else { - - // If the timestamp of the entity the client got is older than + // If the timestamp of the entity the client got differs from // the last modification date of the entity, the entire entity // is returned. - if (lastModified > (headerValueTime + 1000)) + if (Math.abs(lastModified -headerValueTime) > 1000) { return FULL; - + } } - } long fileLength = resource.getContentLength(); if (fileLength == 0) { - return null; + // Range header makes no sense for a zero length resource. Tomcat + // therefore opts to ignore it. + return FULL; } // Retrieving the range header (if any is specified String rangeHeader = request.getHeader("Range"); if (rangeHeader == null) { - return null; + // No Range header is the same as ignoring any Range header + return FULL; } - // bytes is the only range unit supported (and I don't see the point - // of adding new ones). - if (!rangeHeader.startsWith("bytes")) { + Ranges ranges = Ranges.parse(new StringReader(rangeHeader)); + + if (ranges == null) { + // The Range header is present but not formatted correctly. + // Could argue for a 400 response but 416 is more specific. + // There is also the option to ignore the (invalid) Range header. + // RFC7233#4.4 notes that many servers do ignore the Range header in + // these circumstances but Tomcat has always returned a 416. response.addHeader("Content-Range", "bytes */" + fileLength); - response.sendError - (HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); + response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); return null; } - rangeHeader = rangeHeader.substring(6); + // bytes is the only range unit supported (and I don't see the point + // of adding new ones). + if (!ranges.getUnits().equals("bytes")) { + // RFC7233#3.1 Servers must ignore range units they don't understand + return FULL; + } - // Collection which will contain all the ranges which are successfully - // parsed. + // TODO: Remove the internal representation and use Ranges + // Convert to internal representation ArrayList<Range> result = new ArrayList<>(); - StringTokenizer commaTokenizer = new StringTokenizer(rangeHeader, ","); - - // Parsing the range list - while (commaTokenizer.hasMoreTokens()) { - String rangeDefinition = commaTokenizer.nextToken().trim(); + for (Ranges.Entry entry : ranges.getEntries()) { Range currentRange = new Range(); - currentRange.length = fileLength; - - int dashPos = rangeDefinition.indexOf('-'); - - if (dashPos == -1) { - response.addHeader("Content-Range", "bytes */" + fileLength); - response.sendError - (HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); - return null; - } - - if (dashPos == 0) { - - try { - long offset = Long.parseLong(rangeDefinition); - currentRange.start = fileLength + offset; - currentRange.end = fileLength - 1; - } catch (NumberFormatException e) { - response.addHeader("Content-Range", - "bytes */" + fileLength); - response.sendError - (HttpServletResponse - .SC_REQUESTED_RANGE_NOT_SATISFIABLE); - return null; + if (entry.getStart() == -1) { + currentRange.start = fileLength - entry.getEnd(); + if (currentRange.start < 0) { + currentRange.start = 0; } - + currentRange.end = fileLength - 1; + } else if (entry.getEnd() == -1) { + currentRange.start = entry.getStart(); + currentRange.end = fileLength - 1; } else { - - try { - currentRange.start = Long.parseLong - (rangeDefinition.substring(0, dashPos)); - if (dashPos < rangeDefinition.length() - 1) - currentRange.end = Long.parseLong - (rangeDefinition.substring - (dashPos + 1, rangeDefinition.length())); - else - currentRange.end = fileLength - 1; - } catch (NumberFormatException e) { - response.addHeader("Content-Range", - "bytes */" + fileLength); - response.sendError - (HttpServletResponse - .SC_REQUESTED_RANGE_NOT_SATISFIABLE); - return null; - } - + currentRange.start = entry.getStart(); + currentRange.end = entry.getEnd(); } + currentRange.length = fileLength; if (!currentRange.validate()) { response.addHeader("Content-Range", "bytes */" + fileLength); - response.sendError - (HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); + response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); return null; } diff --git a/java/org/apache/tomcat/util/http/parser/ContentRange.java b/java/org/apache/tomcat/util/http/parser/ContentRange.java new file mode 100644 index 0000000..59bf071 --- /dev/null +++ b/java/org/apache/tomcat/util/http/parser/ContentRange.java @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tomcat.util.http.parser; + +import java.io.IOException; +import java.io.StringReader; + +public class ContentRange { + + private final String units; + private final long start; + private final long end; + private final long length; + + + public ContentRange(String units, long start, long end, long length) { + this.units = units; + this.start = start; + this.end = end; + this.length = length; + } + + + public String getUnits() { + return units; + } + + + public long getStart() { + return start; + } + + + public long getEnd() { + return end; + } + + + public long getLength() { + return length; + } + + + /** + * Parses a Content-Range header from an HTTP header. + * + * @param input a reader over the header text + * + * @return the range parsed from the input, or null if not valid + * + * @throws IOException if there was a problem reading the input + */ + public static ContentRange parse(StringReader input) throws IOException { + // Units (required) + String units = HttpParser.readToken(input); + if (units == null || units.length() == 0) { + return null; + } + + // Must be followed by '=' + if (HttpParser.skipConstant(input, "=") == SkipResult.NOT_FOUND) { + return null; + } + + // Start + long start = HttpParser.readLong(input); + + // Must be followed by '-' + if (HttpParser.skipConstant(input, "-") == SkipResult.NOT_FOUND) { + return null; + } + + // End + long end = HttpParser.readLong(input); + + // Must be followed by '/' + if (HttpParser.skipConstant(input, "/") == SkipResult.NOT_FOUND) { + return null; + } + + // Length + long length = HttpParser.readLong(input); + + // Doesn't matter what we look for, result should be EOF + SkipResult skipResult = HttpParser.skipConstant(input, "X"); + + if (skipResult != SkipResult.EOF) { + // Invalid range + return null; + } + + return new ContentRange(units, start, end, length); + } +} diff --git a/java/org/apache/tomcat/util/http/parser/HttpParser.java b/java/org/apache/tomcat/util/http/parser/HttpParser.java index 8598321..f76ddcb 100644 --- a/java/org/apache/tomcat/util/http/parser/HttpParser.java +++ b/java/org/apache/tomcat/util/http/parser/HttpParser.java @@ -405,6 +405,43 @@ public class HttpParser { } /** + * @return the digits if any were found, the empty string if no data was + * found or if data other than digits was found + */ + static String readDigits(Reader input) throws IOException { + StringBuilder result = new StringBuilder(); + + skipLws(input); + input.mark(1); + int c = input.read(); + + while (c != -1 && isNumeric(c)) { + result.append((char) c); + input.mark(1); + c = input.read(); + } + // Use mark(1)/reset() rather than skip(-1) since skip() is a NOP + // once the end of the String has been reached. + input.reset(); + + return result.toString(); + } + + /** + * @return the number if digits were found, -1 if no data was found + * or if data other than digits was found + */ + static long readLong(Reader input) throws IOException { + String digits = readDigits(input); + + if (digits.length() == 0) { + return -1; + } + + return Long.parseLong(digits); + } + + /** * @return the quoted string if one was found, null if data other than a * quoted string was found or null if the end of data was reached * before the quoted string was terminated diff --git a/java/org/apache/tomcat/util/http/parser/Ranges.java b/java/org/apache/tomcat/util/http/parser/Ranges.java new file mode 100644 index 0000000..c937eb5 --- /dev/null +++ b/java/org/apache/tomcat/util/http/parser/Ranges.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tomcat.util.http.parser; + +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class Ranges { + + private final String units; + private final List<Entry> entries; + + + private Ranges(String units, List<Entry> entries) { + this.units = units; + this.entries = Collections.unmodifiableList(entries); + } + + + public List<Entry> getEntries() { + return entries; + } + + public String getUnits() { + return units; + } + + + public static class Entry { + + private final long start; + private final long end; + + + public Entry(long start, long end) { + this.start = start; + this.end = end; + } + + + public long getStart() { + return start; + } + + + public long getEnd() { + return end; + } + } + + + /** + * Parses a Range header from an HTTP header. + * + * @param input a reader over the header text + * + * @return a set of ranges parsed from the input, or null if not valid + * + * @throws IOException if there was a problem reading the input + */ + public static Ranges parse(StringReader input) throws IOException { + + // Units (required) + String units = HttpParser.readToken(input); + if (units == null || units.length() == 0) { + return null; + } + + // Must be followed by '=' + if (HttpParser.skipConstant(input, "=") == SkipResult.NOT_FOUND) { + return null; + } + + // Range entries + List<Entry> entries = new ArrayList<>(); + + SkipResult skipResult; + do { + long start = HttpParser.readLong(input); + // Must be followed by '-' + if (HttpParser.skipConstant(input, "-") == SkipResult.NOT_FOUND) { + return null; + } + long end = HttpParser.readLong(input); + + if (start == -1 && end == -1) { + // Invalid range + return null; + } + + entries.add(new Entry(start, end)); + + skipResult = HttpParser.skipConstant(input, ","); + if (skipResult == SkipResult.NOT_FOUND) { + // Invalid range + return null; + } + } while (skipResult == SkipResult.FOUND); + + // There must be at least one entry + if (entries.size() == 0) { + return null; + } + + return new Ranges(units, entries); + } +} diff --git a/test/org/apache/catalina/servlets/TestDefaultServletPut.java b/test/org/apache/catalina/servlets/TestDefaultServletPut.java new file mode 100644 index 0000000..915c448 --- /dev/null +++ b/test/org/apache/catalina/servlets/TestDefaultServletPut.java @@ -0,0 +1,185 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.catalina.servlets; + +import java.io.File; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; + +import static org.apache.catalina.startup.SimpleHttpClient.CRLF; +import org.apache.catalina.Context; +import org.apache.catalina.Wrapper; +import org.apache.catalina.startup.ExpandWar; +import org.apache.catalina.startup.SimpleHttpClient; +import org.apache.catalina.startup.Tomcat; +import org.apache.catalina.startup.TomcatBaseTest; +import org.apache.tomcat.util.buf.ByteChunk; + +@RunWith(Parameterized.class) +public class TestDefaultServletPut extends TomcatBaseTest { + + private static final String START_TEXT= "Starting text"; + private static final String START_LEN = Integer.toString(START_TEXT.length()); + private static final String PATCH_TEXT= "Ending *"; + private static final String PATCH_LEN = Integer.toString(PATCH_TEXT.length()); + private static final String END_TEXT= "Ending * text"; + + @Parameterized.Parameters(name = "{index} rangeHeader [{0}]") + public static Collection<Object[]> parameters() { + List<Object[]> parameterSets = new ArrayList<>(); + + // Valid partial PUT + parameterSets.add(new Object[] { + "Content-Range: bytes=0-" + PATCH_LEN + "/" + START_LEN + CRLF, Boolean.TRUE, END_TEXT }); + // Full PUT + parameterSets.add(new Object[] { + "", null, PATCH_TEXT }); + // Invalid range + parameterSets.add(new Object[] { + "Content-Range: apples=0-" + PATCH_LEN + "/" + START_LEN + CRLF, Boolean.FALSE, START_TEXT }); + parameterSets.add(new Object[] { + "Content-Range: bytes00-" + PATCH_LEN + "/" + START_LEN + CRLF, Boolean.FALSE, START_TEXT }); + parameterSets.add(new Object[] { + "Content-Range: bytes=9-7/" + START_LEN + CRLF, Boolean.FALSE, START_TEXT }); + parameterSets.add(new Object[] { + "Content-Range: bytes=-7/" + START_LEN + CRLF, Boolean.FALSE, START_TEXT }); + parameterSets.add(new Object[] { + "Content-Range: bytes=9-/" + START_LEN + CRLF, Boolean.FALSE, START_TEXT }); + parameterSets.add(new Object[] { + "Content-Range: bytes=9-X/" + START_LEN + CRLF, Boolean.FALSE, START_TEXT }); + parameterSets.add(new Object[] { + "Content-Range: bytes=0-5/" + CRLF, Boolean.FALSE, START_TEXT }); + parameterSets.add(new Object[] { + "Content-Range: bytes=0-5/0x5" + CRLF, Boolean.FALSE, START_TEXT }); + + return parameterSets; + } + + + private File tempDocBase; + + @Parameter(0) + public String contentRangeHeader; + + @Parameter(1) + public Boolean contentRangeHeaderValid; + + @Parameter(2) + public String expectedEndText; + + @Override + public void setUp() throws Exception { + super.setUp(); + tempDocBase = Files.createTempDirectory(getTemporaryDirectory().toPath(), "put").toFile(); + } + + + /* + * Replaces the text at the start of START_TEXT with PATCH_TEXT. + */ + @Test + public void testPut() throws Exception { + // Configure a web app with a read/write default servlet + Tomcat tomcat = getTomcatInstance(); + Context ctxt = tomcat.addContext("", tempDocBase.getAbsolutePath()); + + Wrapper w = Tomcat.addServlet(ctxt, "default", DefaultServlet.class.getName()); + w.addInitParameter("readonly", "false"); + ctxt.addServletMappingDecoded("/", "default"); + + tomcat.start(); + + // Disable caching + ctxt.getResources().setCachingAllowed(false); + + // Full PUT + PutClient putClient = new PutClient(getPort()); + + putClient.setRequest(new String[] { + "PUT /test.txt HTTP/1.1" + CRLF + + "Host: localhost:" + getPort() + CRLF + + "Content-Length: " + START_LEN + CRLF + + CRLF + + START_TEXT + }); + putClient.connect(); + putClient.processRequest(false); + Assert.assertTrue(putClient.isResponse201()); + putClient.disconnect(); + + putClient.reset(); + + // Partial PUT + putClient.connect(); + putClient.setRequest(new String[] { + "PUT /test.txt HTTP/1.1" + CRLF + + "Host: localhost:" + getPort() + CRLF + + contentRangeHeader + + "Content-Length: " + PATCH_LEN + CRLF + + CRLF + + PATCH_TEXT + }); + putClient.processRequest(false); + if (contentRangeHeaderValid == null) { + // Not present (so will do a full PUT, replacing the existing) + Assert.assertTrue(putClient.isResponse204()); + } else if (contentRangeHeaderValid.booleanValue()) { + // Valid + Assert.assertTrue(putClient.isResponse204()); + } else { + // Not valid + Assert.assertTrue(putClient.isResponse400()); + } + + // Check for the final resource + String path = "http://localhost:" + getPort() + "/test.txt"; + ByteChunk responseBody = new ByteChunk(); + + int rc = getUrl(path, responseBody, null); + + Assert.assertEquals(200, rc); + Assert.assertEquals(expectedEndText, responseBody.toString()); + } + + + @Override + public void tearDown() { + ExpandWar.deleteDir(tempDocBase, false); + } + + + private static class PutClient extends SimpleHttpClient { + + public PutClient(int port) { + setPort(port); + } + + + @Override + public boolean isResponseBodyOK() { + return false; + } + } +} diff --git a/test/org/apache/catalina/servlets/TestDefaultServletRangeRequests.java b/test/org/apache/catalina/servlets/TestDefaultServletRangeRequests.java new file mode 100644 index 0000000..c50cc4b --- /dev/null +++ b/test/org/apache/catalina/servlets/TestDefaultServletRangeRequests.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.catalina.servlets; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; + +import org.apache.catalina.Context; +import org.apache.catalina.startup.Tomcat; +import org.apache.catalina.startup.TomcatBaseTest; +import org.apache.tomcat.util.buf.ByteChunk; + +@RunWith(Parameterized.class) +public class TestDefaultServletRangeRequests extends TomcatBaseTest { + + @Parameterized.Parameters(name = "{index} rangeHeader [{0}]") + public static Collection<Object[]> parameters() { + + // Get the length of the file used for this test + // It varies by platform due to line-endings + File index = new File("test/webapp/index.html"); + long len = index.length(); + String strLen = Long.toString(len); + + List<Object[]> parameterSets = new ArrayList<>(); + + parameterSets.add(new Object[] { "", Integer.valueOf(200), strLen, "" }); + // Invalid + parameterSets.add(new Object[] { "bytes", Integer.valueOf(416), "", "*/" + len }); + parameterSets.add(new Object[] { "bytes=", Integer.valueOf(416), "", "*/" + len }); + // Invalid with unknown type + parameterSets.add(new Object[] { "unknown", Integer.valueOf(416), "", "*/" + len }); + parameterSets.add(new Object[] { "unknown=", Integer.valueOf(416), "", "*/" + len }); + // Invalid ranges + parameterSets.add(new Object[] { "bytes=-", Integer.valueOf(416), "", "*/" + len }); + parameterSets.add(new Object[] { "bytes=10-b", Integer.valueOf(416), "", "*/" + len }); + parameterSets.add(new Object[] { "bytes=b-10", Integer.valueOf(416), "", "*/" + len }); + // Invalid no equals + parameterSets.add(new Object[] { "bytes 1-10", Integer.valueOf(416), "", "*/" + len }); + parameterSets.add(new Object[] { "bytes1-10", Integer.valueOf(416), "", "*/" + len }); + parameterSets.add(new Object[] { "bytes10-", Integer.valueOf(416), "", "*/" + len }); + parameterSets.add(new Object[] { "bytes-10", Integer.valueOf(416), "", "*/" + len }); + // Unknown types + parameterSets.add(new Object[] { "unknown=1-2", Integer.valueOf(200), strLen, "" }); + parameterSets.add(new Object[] { "bytesX=1-2", Integer.valueOf(200), strLen, "" }); + parameterSets.add(new Object[] { "Xbytes=1-2", Integer.valueOf(200), strLen, "" }); + // Valid range + parameterSets.add(new Object[] { "bytes=0-9", Integer.valueOf(206), "10", "0-9/" + len }); + parameterSets.add(new Object[] { "bytes=-100", Integer.valueOf(206), "100", (len - 100) + "-" + (len - 1) + "/" + len }); + parameterSets.add(new Object[] { "bytes=100-", Integer.valueOf(206), "" + (len - 100), "100-" + (len - 1) + "/" + len }); + // Valid range (too much) + parameterSets.add(new Object[] { "bytes=0-1000", Integer.valueOf(206), strLen, "0-" + (len - 1) + "/" + len }); + parameterSets.add(new Object[] { "bytes=-1000", Integer.valueOf(206), strLen, "0-" + (len - 1) + "/" + len }); + return parameterSets; + } + + @Parameter(0) + public String rangeHeader; + @Parameter(1) + public int responseCodeExpected; + @Parameter(2) + public String contentLengthExpected; + @Parameter(3) + public String responseRangeExpected; + + @Test + public void testRange() throws Exception { + + Tomcat tomcat = getTomcatInstance(); + + File appDir = new File("test/webapp"); + Context ctxt = tomcat.addContext("", appDir.getAbsolutePath()); + + Tomcat.addServlet(ctxt, "default", DefaultServlet.class.getName()); + ctxt.addServletMappingDecoded("/", "default"); + + tomcat.start(); + + // Set up parameters + String path = "http://localhost:" + getPort() + "/index.html"; + ByteChunk responseBody = new ByteChunk(); + Map<String,List<String>> responseHeaders = new HashMap<>(); + + int rc = getUrl(path, responseBody, buildRangeHeader(rangeHeader), responseHeaders); + + // Check the result + Assert.assertEquals(responseCodeExpected, rc); + + if (contentLengthExpected.length() > 0) { + String contentLength = responseHeaders.get("Content-Length").get(0); + Assert.assertEquals(contentLengthExpected, contentLength); + } + + if (responseRangeExpected.length() > 0) { + String responseRange = null; + List<String> headerValues = responseHeaders.get("Content-Range"); + if (headerValues != null && headerValues.size() == 1) { + responseRange = headerValues.get(0); + } + Assert.assertEquals("bytes " + responseRangeExpected, responseRange); + } + } + + + private static Map<String,List<String>> buildRangeHeader(String... headerValues) { + Map<String,List<String>> requestHeaders = new HashMap<>(); + List<String> values = new ArrayList<>(); + for (String headerValue : headerValues) { + if (headerValue.length() > 0) { + values.add(headerValue); + } + } + + if (values.size() == 0) { + return null; + } + + requestHeaders.put("range", values); + + return requestHeaders; + } +} diff --git a/test/org/apache/catalina/startup/SimpleHttpClient.java b/test/org/apache/catalina/startup/SimpleHttpClient.java index 05cbb83..ec0af7d 100644 --- a/test/org/apache/catalina/startup/SimpleHttpClient.java +++ b/test/org/apache/catalina/startup/SimpleHttpClient.java @@ -48,6 +48,8 @@ public abstract class SimpleHttpClient { public static final String INFO_100 = "HTTP/1.1 100 "; public static final String OK_200 = "HTTP/1.1 200 "; + public static final String CREATED_201 = "HTTP/1.1 201 "; + public static final String NOCONTENT_204 = "HTTP/1.1 204 "; public static final String REDIRECT_302 = "HTTP/1.1 302 "; public static final String REDIRECT_303 = "HTTP/1.1 303 "; public static final String FAIL_400 = "HTTP/1.1 400 "; @@ -411,6 +413,14 @@ public abstract class SimpleHttpClient { return responseLineStartsWith(OK_200); } + public boolean isResponse201() { + return responseLineStartsWith(CREATED_201); + } + + public boolean isResponse204() { + return responseLineStartsWith(NOCONTENT_204); + } + public boolean isResponse302() { return responseLineStartsWith(REDIRECT_302); } @@ -460,4 +470,4 @@ public abstract class SimpleHttpClient { } public abstract boolean isResponseBodyOK(); -} \ No newline at end of file +} diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index cc15118..bccc8b4 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -80,6 +80,27 @@ that changes to <code>$CATALINA_BASE/conf/tomcat-users.xml</code> will now take effect a short time after the file is saved. (markt) </add> + <fix> + Improve parsing of Range request headers. (markt) + </fix> + <fix> + Range headers that specify a range unit Tomcat does not recognise should + be ignored rather than triggering a 416 response. Based on a pull + request by zhanhb. (markt) + </fix> + <fix> + When comparing a date from a <code>If-Range</code> header, an exact + match is required. Based on a pull request by zhanhb. (markt) + </fix> + <fix> + Add an option to the default servlet to disable processing of PUT + requests with Content-Range headers as partial PUTs. The default + behaviour (processing as partial PUT) is unchanged. Based on a pull + request by zhanhb. (markt) + </fix> + <fix> + Improve parsing of Content-Range headers. (markt) + </fix> </changelog> </subsection> <subsection name="Coyote"> --------------------------------------------------------------------- To unsubscribe, e-mail: dev-unsubscr...@tomcat.apache.org For additional commands, e-mail: dev-h...@tomcat.apache.org