From 64ee4fab55bb850d790fde45ce92d9cd5f6a7663 Mon Sep 17 00:00:00 2001 From: remm Date: Wed, 11 Dec 2024 10:56:06 +0100 Subject: [PATCH] Improve HTTP If headers processing according to RFC 9110 PR#796 by Chenjp. Also includes better test cases. --- .../catalina/servlets/DefaultServlet.java | 376 +++++++--- .../TestDefaultServletRfc9110Section13.java | 672 ++++++++++++++++++ ...tServletRfc9110Section13Parameterized.java | 433 +++++++++++ webapps/docs/changelog.xml | 4 + 4 files changed, 1387 insertions(+), 98 deletions(-) create mode 100644 test/org/apache/catalina/servlets/TestDefaultServletRfc9110Section13.java create mode 100644 test/org/apache/catalina/servlets/TestDefaultServletRfc9110Section13Parameterized.java diff --git a/java/org/apache/catalina/servlets/DefaultServlet.java b/java/org/apache/catalina/servlets/DefaultServlet.java index 2accac435e38..a8cb7525d05a 100644 --- a/java/org/apache/catalina/servlets/DefaultServlet.java +++ b/java/org/apache/catalina/servlets/DefaultServlet.java @@ -749,10 +749,72 @@ protected void doDelete(HttpServletRequest req, HttpServletResponse resp) throws */ protected boolean checkIfHeaders(HttpServletRequest request, HttpServletResponse response, WebResource resource) throws IOException { + String ifNoneMatchHeader = request.getHeader("If-None-Match"); - return checkIfMatch(request, response, resource) && checkIfModifiedSince(request, response, resource) && - checkIfNoneMatch(request, response, resource) && checkIfUnmodifiedSince(request, response, resource); - + // RFC9110 #13.3.2 defines preconditions evaluation order + int next = 1; + while (true) { + switch (next) { + case 1: + if (request.getHeader("If-Match") != null) { + if (checkIfMatch(request, response, resource)) { + next = 3; + } else { + return false; + } + } else { + next = 2; + } + break; + case 2: + if (request.getHeader("If-Unmodified-Since") != null) { + if (checkIfUnmodifiedSince(request, response, resource)) { + next = 3; + } else { + return false; + } + } else { + next = 3; + } + break; + case 3: + if (ifNoneMatchHeader != null) { + if (checkIfNoneMatch(request, response, resource)) { + next = 5; + } else { + return false; + } + } else { + next = 4; + } + break; + case 4: + if (("GET".equals(request.getMethod()) || "HEAD".equals(request.getMethod())) && + ifNoneMatchHeader == null && request.getHeader("If-Modified-Since") != null) { + if (checkIfModifiedSince(request, response, resource)) { + next = 5; + } else { + return false; + } + } else { + next = 5; + } + break; + case 5: + if ("GET".equals(request.getMethod()) && request.getHeader("If-Range") != null + && request.getHeader("Range") != null) { + if (checkIfRange(request, response, resource) && determineRangeRequestsApplicable(resource)) { + // Partial content, precondition passed + return true; + } else { + // ignore the Range header field + return true; + } + } else { + return true; + } + } + } } @@ -848,15 +910,6 @@ protected void serveResource(HttpServletRequest request, HttpServletResponse res } boolean included = false; - // Check if the conditions specified in the optional If headers are - // satisfied. - if (resource.isFile()) { - // Checking If headers - included = (request.getAttribute(RequestDispatcher.INCLUDE_CONTEXT_PATH) != null); - if (!included && !isError && !checkIfHeaders(request, response, resource)) { - return; - } - } // Find content type. String contentType = resource.getMimeType(); @@ -870,11 +923,21 @@ protected void serveResource(HttpServletRequest request, HttpServletResponse res // be needed later String eTag = null; String lastModifiedHttp = null; + if (resource.isFile() && !isError) { eTag = generateETag(resource); lastModifiedHttp = resource.getLastModifiedHttp(); } + // Check if the conditions specified in the optional If headers are + // satisfied. + if (resource.isFile()) { + // Checking If headers + included = (request.getAttribute(RequestDispatcher.INCLUDE_CONTEXT_PATH) != null); + if (!included && !isError && !checkIfHeaders(request, response, resource)) { + return; + } + } // Serve a precompressed version of the file if present boolean usingPrecompressedVersion = false; @@ -1470,41 +1533,24 @@ protected ContentRange parseContentRange(HttpServletRequest request, HttpServlet protected Ranges parseRange(HttpServletRequest request, HttpServletResponse response, WebResource resource) throws IOException { - if (!"GET".equals(request.getMethod())) { + // Retrieving the range header (if any is specified) + String rangeHeader = request.getHeader("Range"); + + if (rangeHeader == null) { + // No Range header is the same as ignoring any Range header + return FULL; + } + + if (!"GET".equals(request.getMethod()) || !determineRangeRequestsApplicable(resource)) { // RFC 9110 - Section 14.2: GET is the only method for which range handling is defined. // Otherwise MUST ignore a Range header field return FULL; } - // Checking If-Range - String headerValue = request.getHeader("If-Range"); - - if (headerValue != null) { - - long headerValueTime = (-1L); - try { - headerValueTime = request.getDateHeader("If-Range"); - } catch (IllegalArgumentException e) { - // Ignore - } - - String eTag = generateETag(resource); - 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())) { - return FULL; - } - } else { - // If the timestamp of the entity the client got differs from - // the last modification date of the entity, the entire entity - // is returned. - if (Math.abs(lastModified - headerValueTime) > 1000) { - return FULL; - } - } + // Although If-Range evaluation was performed previously, the result were not propagated. + // Hence we have to evaluate If-Range again. + if (!checkIfRange(request, response, resource)) { + return FULL; } long fileLength = resource.getContentLength(); @@ -1515,13 +1561,6 @@ protected Ranges parseRange(HttpServletRequest request, HttpServletResponse resp return FULL; } - // Retrieving the range header (if any is specified) - String rangeHeader = request.getHeader("Range"); - - if (rangeHeader == null) { - // No Range header is the same as ignoring any Range header - return FULL; - } Ranges ranges = Ranges.parse(new StringReader(rangeHeader)); @@ -2166,36 +2205,49 @@ protected boolean checkSendfile(HttpServletRequest request, HttpServletResponse protected boolean checkIfMatch(HttpServletRequest request, HttpServletResponse response, WebResource resource) throws IOException { - String headerValue = request.getHeader("If-Match"); - if (headerValue != null) { - - boolean conditionSatisfied; + String resourceETag = generateETag(resource); + if (resourceETag == null) { + // if a current representation for the target resource is not present + response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); + return false; + } - if (!headerValue.equals("*")) { - String resourceETag = generateETag(resource); - if (resourceETag == null) { - conditionSatisfied = false; - } else { - // RFC 7232 requires strong comparison for If-Match headers - Boolean matched = EntityTag.compareEntityTag(new StringReader(headerValue), false, resourceETag); - if (matched == null) { - if (debug > 10) { - log("DefaultServlet.checkIfMatch: Invalid header value [" + headerValue + "]"); - } - response.sendError(HttpServletResponse.SC_BAD_REQUEST); - return false; + boolean conditionSatisfied = false; + Enumeration headerValues = request.getHeaders("If-Match"); + if (!headerValues.hasMoreElements()) { + return true; + } + boolean hasAsteriskValue = false;// check existence of special header value '*' + while (headerValues.hasMoreElements() && !conditionSatisfied) { + String headerValue = headerValues.nextElement(); + if ("*".equals(headerValue)) { + hasAsteriskValue = true; + conditionSatisfied = true; + } else { + // RFC 7232 requires strong comparison for If-Match headers + Boolean matched = EntityTag.compareEntityTag(new StringReader(headerValue), false, resourceETag); + if (matched == null) { + if (debug > 10) { + log("DefaultServlet.checkIfMatch: Invalid header value [" + headerValue + "]"); } + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + return false; + } else { conditionSatisfied = matched.booleanValue(); } - } else { - conditionSatisfied = true; - } - - if (!conditionSatisfied) { - response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); - return false; } } + if (hasAsteriskValue && headerValues.hasMoreElements()) { + // Note that an If-Match header field with a list value containing "*" and other values (including other + // instances of "*") is syntactically invalid (therefore not allowed to be generated) and furthermore is + // unlikely to be interoperable. + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + return false; + } + if (!conditionSatisfied) { + response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); + return false; + } return true; } @@ -2212,14 +2264,16 @@ protected boolean checkIfMatch(HttpServletRequest request, HttpServletResponse r */ protected boolean checkIfModifiedSince(HttpServletRequest request, HttpServletResponse response, WebResource resource) { + + long resourceLastModified = resource.getLastModified(); + try { long headerValue = request.getDateHeader("If-Modified-Since"); - long lastModified = resource.getLastModified(); if (headerValue != -1) { // If an If-None-Match header has been specified, if modified since // is ignored. - if ((request.getHeader("If-None-Match") == null) && (lastModified < headerValue + 1000)) { + if ((request.getHeader("If-None-Match") == null) && (resourceLastModified < headerValue + 1000)) { // The entity has not been modified since the date // specified by the client. This is not an error case. response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); @@ -2250,15 +2304,39 @@ protected boolean checkIfModifiedSince(HttpServletRequest request, HttpServletRe protected boolean checkIfNoneMatch(HttpServletRequest request, HttpServletResponse response, WebResource resource) throws IOException { - String headerValue = request.getHeader("If-None-Match"); - if (headerValue != null) { + String resourceETag = generateETag(resource); - boolean conditionSatisfied; + Enumeration headerValues = request.getHeaders("If-None-Match"); + if (!headerValues.hasMoreElements()) { + return true; + } + boolean hasAsteriskValue = false;// check existence of special header value '*' + boolean conditionSatisfied = true; + while (headerValues.hasMoreElements()) { - String resourceETag = generateETag(resource); - if (!headerValue.equals("*")) { - if (resourceETag == null) { + String headerValue = headerValues.nextElement(); + + if (headerValue.equals("*")) { + hasAsteriskValue = true; + if (headerValues.hasMoreElements()) { conditionSatisfied = false; + break; + } else { + // asterisk '*' is the only field value. + // RFC9110: If the field value is "*", the condition is false if the origin server has a current + // representation for the target resource. + if (resourceETag != null) { + conditionSatisfied = false; + } else { + conditionSatisfied = true; + } + break; + } + } else { + if (resourceETag == null) { + // None of the entity tag matches. + conditionSatisfied = true; + break; } else { // RFC 7232 requires weak comparison for If-None-Match headers Boolean matched = EntityTag.compareEntityTag(new StringReader(headerValue), true, resourceETag); @@ -2269,25 +2347,37 @@ protected boolean checkIfNoneMatch(HttpServletRequest request, HttpServletRespon response.sendError(HttpServletResponse.SC_BAD_REQUEST); return false; } - conditionSatisfied = matched.booleanValue(); + if (matched.booleanValue()) { + // RFC9110: If the field value is a list of entity tags, the condition is false if one of the + // listed tags + // matches the entity tag of the selected representation. + conditionSatisfied = false; + break; + } } - } else { - conditionSatisfied = true; } - if (conditionSatisfied) { - // For GET and HEAD, we should respond with - // 304 Not Modified. - // For every other method, 412 Precondition Failed is sent - // back. - if ("GET".equals(request.getMethod()) || "HEAD".equals(request.getMethod())) { - response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); - response.setHeader("ETag", resourceETag); - } else { - response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); - } - return false; + } + + if (hasAsteriskValue && headerValues.hasMoreElements()) { + // Note that an If-None-Match header field with a list value containing "*" and other values (including + // other instances of "*") is syntactically invalid (therefore not allowed to be generated) and furthermore + // is unlikely to be interoperable. + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + return false; + } + if (!conditionSatisfied) { + // For GET and HEAD, we should respond with + // 304 Not Modified. + // For every other method, 412 Precondition Failed is sent + // back. + if ("GET".equals(request.getMethod()) || "HEAD".equals(request.getMethod())) { + response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); + response.setHeader("ETag", resourceETag); + } else { + response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); } + return false; } return true; } @@ -2307,11 +2397,28 @@ protected boolean checkIfNoneMatch(HttpServletRequest request, HttpServletRespon */ protected boolean checkIfUnmodifiedSince(HttpServletRequest request, HttpServletResponse response, WebResource resource) throws IOException { + + long resourceLastModified = resource.getLastModified(); + if (resourceLastModified <= -1 || request.getHeader("If-Match") != null) { + // MUST ignore if the resource does not have a modification date available. + // MUST ignore if the request contains an If-Match header field + return true; + } + Enumeration headerEnum = request.getHeaders("If-Unmodified-Since"); + if (!headerEnum.hasMoreElements()) { + // If-Unmodified-Since is not present + return true; + } + headerEnum.nextElement(); + if (headerEnum.hasMoreElements()) { + // If-Unmodified-Since is a list of dates + return true; + } + try { - long lastModified = resource.getLastModified(); long headerValue = request.getDateHeader("If-Unmodified-Since"); if (headerValue != -1) { - if (lastModified >= (headerValue + 1000)) { + if (resourceLastModified >= (headerValue + 1000)) { // The entity has not been modified since the date // specified by the client. This is not an error case. response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); @@ -2325,6 +2432,79 @@ protected boolean checkIfUnmodifiedSince(HttpServletRequest request, HttpServlet } + /** + * Check if the if-range condition is satisfied. + * + * @param request The servlet request we are processing + * @param response The servlet response we are creating + * @param resource The resource + * + * @return true if the resource meets the specified condition, and false if the condition + * is not satisfied, resulting in transfer of the new selected representation instead of a 412 + * (Precondition Failed) response. + * + * @throws IOException an IO error occurred + */ + protected boolean checkIfRange(HttpServletRequest request, HttpServletResponse response, WebResource resource) + throws IOException { + String resourceETag = generateETag(resource); + long resourceLastModified = resource.getLastModified(); + + String headerValue = request.getHeader("If-Range"); + if (headerValue == null) { + return true; + } + + String rangeHeader = request.getHeader("Range"); + if (rangeHeader == null || !determineRangeRequestsApplicable(resource)) { + // Simply ignore If-Range header field + return true; + } + + long headerValueTime = (-1L); + try { + headerValueTime = request.getDateHeader("If-Range"); + } catch (IllegalArgumentException e) { + // Ignore + } + + if (headerValueTime == (-1L)) { + // If the ETag the client gave does not match the entity + // etag, then the entire entity is returned. + if (resourceETag != null && resourceETag.startsWith("\"") && resourceETag.equals(headerValue.trim())) { + return true; + } else { + return false; + } + } else { + // unit of HTTP date is second, ignore millisecond part. + return resourceLastModified >= headerValueTime && resourceLastModified < headerValueTime + 1000; + } + } + + /** + * Checks if range request is supported by server + * + * @return true server supports range requests feature. + */ + protected boolean isRangeRequestsSupported() { + // Range-Requests optional feature is enabled implicitly. + return true; + } + + /** + * Determines if range-request is applicable for the target resource. + *

+ * Subclass have an opportunity to customize by overriding this method. + * + * @param resource the target resource + * + * @return true only if range requests is supported by both the server and the target resource. + */ + protected boolean determineRangeRequestsApplicable(WebResource resource) { + return isRangeRequestsSupported() && resource.isFile() && resource.exists(); + } + /** * Provides the entity tag (the ETag header) for the given resource. Intended to be over-ridden by custom * DefaultServlet implementations that wish to use an alternative format for the entity tag. diff --git a/test/org/apache/catalina/servlets/TestDefaultServletRfc9110Section13.java b/test/org/apache/catalina/servlets/TestDefaultServletRfc9110Section13.java new file mode 100644 index 000000000000..f191e6f0275d --- /dev/null +++ b/test/org/apache/catalina/servlets/TestDefaultServletRfc9110Section13.java @@ -0,0 +1,672 @@ +/* + * 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.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.function.IntPredicate; + +import jakarta.servlet.http.HttpServletResponse; + +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; + +import org.apache.catalina.Context; +import org.apache.catalina.Wrapper; +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; +import org.apache.tomcat.util.http.FastHttpDateFormat; + +public class TestDefaultServletRfc9110Section13 extends TomcatBaseTest { + + @Test + public void testPreconditions2_2_1_head0() throws Exception { + startServer(true); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_ALL, null, null, null, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, null, null, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_IN, null, null, null, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_NOT_IN, null, null, null, null, 412); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_SYNTAX_INVALID, null, null, null, null, 400); + } + + @Test + public void testPreconditions2_2_1_head1() throws Exception { + startServer(false); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_ALL, null, null, null, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, null, null, null, 412); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_IN, null, null, null, null, 412); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_NOT_IN, null, null, null, null, 412); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_SYNTAX_INVALID, null, null, null, null, 400); + } + + @Test + public void testPreconditions2_2_2_head0() throws Exception { + startServer(true); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_LT, null, null, null, 412); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_GT, null, null, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_MULTI_IN, null, null, null, 200); + } + + @Test + public void testPreconditions2_2_2_head1() throws Exception { + startServer(false); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_LT, null, null, null, 412); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_GT, null, null, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_MULTI_IN, null, null, null, 200); + } + + @Test + public void testPreconditions2_2_3_head0() throws Exception { + startServer(true); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_IN, null, IfPolicy.ETAG_NOT_IN, null, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, IfPolicy.ETAG_EXACTLY, null, null, 304); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, IfPolicy.ETAG_ALL, null, null, 304); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_EQ, IfPolicy.ETAG_NOT_IN, null, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_GT, IfPolicy.ETAG_EXACTLY, null, null, 304); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_GT, IfPolicy.ETAG_ALL, null, null, 304); + } + + @Test + public void testPreconditions2_2_3_head1() throws Exception { + startServer(false); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_ALL, null, IfPolicy.ETAG_NOT_IN, null, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, IfPolicy.ETAG_EXACTLY, null, null, 304, + 412); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_EQ, IfPolicy.ETAG_NOT_IN, null, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_GT, IfPolicy.ETAG_EXACTLY, null, null, 304); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_GT, IfPolicy.ETAG_ALL, null, null, 304); + } + // @Test + // public void testPreconditions2_2_4_head0() throws Exception { + // startServer(true); + // testPreconditions(Task.HEAD_INDEX_HTML, null, null, null, IfPolicy.DATE_EQ, null, 200); + // testPreconditions(Task.HEAD_INDEX_HTML, null, null, null, IfPolicy.DATE_LT, null, 412); + // testPreconditions(Task.HEAD_INDEX_HTML, null, null, null, IfPolicy.DATE_GT, null, 200); + // testPreconditions(Task.HEAD_INDEX_HTML, null, null, null, IfPolicy.DATE_MULTI, null, 200); + // } + + @Test + public void testPreconditions2_2_4_head1() throws Exception { + startServer(false); + testPreconditions(Task.HEAD_INDEX_HTML, null, null, null, IfPolicy.DATE_EQ, null, 304); + testPreconditions(Task.HEAD_INDEX_HTML, null, null, null, IfPolicy.DATE_LT, null, 200); + testPreconditions(Task.HEAD_INDEX_HTML, null, null, null, IfPolicy.DATE_GT, null, 304); + testPreconditions(Task.HEAD_INDEX_HTML, null, null, null, IfPolicy.DATE_MULTI_IN, null, 200); + + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_ALL, null, IfPolicy.ETAG_NOT_IN, IfPolicy.DATE_EQ, null, + 200); + testPreconditions(Task.HEAD_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, IfPolicy.ETAG_EXACTLY, IfPolicy.DATE_GT, + null, 304, 412); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_EQ, IfPolicy.ETAG_NOT_IN, IfPolicy.DATE_LT, null, + 200); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_GT, IfPolicy.ETAG_EXACTLY, IfPolicy.DATE_MULTI_IN, + null, 304); + testPreconditions(Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_GT, IfPolicy.ETAG_ALL, IfPolicy.DATE_EQ, null, 304); + } + + @Test + public void testPreconditions2_2_1_get0() throws Exception { + startServer(true); + testPreconditions(Task.GET_INDEX_HTML, IfPolicy.ETAG_ALL, null, null, null, null, 200); + } + + @Test + public void testPreconditions2_2_1_get1() throws Exception { + startServer(false); + testPreconditions(Task.GET_INDEX_HTML, IfPolicy.ETAG_ALL, null, null, null, null, 200); + testPreconditions(Task.GET_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, null, null, null, 412); + testPreconditions(Task.GET_INDEX_HTML, IfPolicy.ETAG_IN, null, null, null, null, 412); + testPreconditions(Task.GET_INDEX_HTML, IfPolicy.ETAG_NOT_IN, null, null, null, null, 412); + testPreconditions(Task.GET_INDEX_HTML, IfPolicy.ETAG_SYNTAX_INVALID, null, null, null, null, 400); + } + + @Test + public void testPreconditions2_2_2_get0() throws Exception { + startServer(true); + testPreconditions(Task.GET_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, null, 200); + testPreconditions(Task.GET_INDEX_HTML, null, IfPolicy.DATE_LT, null, null, null, 412); + testPreconditions(Task.GET_INDEX_HTML, null, IfPolicy.DATE_GT, null, null, null, 200); + testPreconditions(Task.GET_INDEX_HTML, null, IfPolicy.DATE_MULTI_IN, null, null, null, 200); + } + + @Test + public void testPreconditions2_2_2_get1() throws Exception { + startServer(false); + testPreconditions(Task.GET_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, null, 200); + testPreconditions(Task.GET_INDEX_HTML, null, IfPolicy.DATE_LT, null, null, null, 412); + testPreconditions(Task.GET_INDEX_HTML, null, IfPolicy.DATE_GT, null, null, null, 200); + testPreconditions(Task.GET_INDEX_HTML, null, IfPolicy.DATE_MULTI_IN, null, null, null, 200); + } + + @Test + public void testPreconditions2_2_5_get0() throws Exception { + startServer(true); + testPreconditions(Task.GET_INDEX_HTML, null, null, null, null, IfPolicy.DATE_EQ, true, 206); + // if-range: multiple node policy, not defined in RFC 9110. + // Currently, tomcat process the first If-Range header simply. + // testPreconditions(Task.GET_INDEX_HTML, null, null, null, null, IfPolicy.DATE_MULTI_IN, true,200); + testPreconditions(Task.GET_INDEX_HTML, null, null, null, null, IfPolicy.DATE_SEMANTIC_INVALID, true, 200); + testPreconditions(Task.GET_INDEX_HTML, null, null, null, null, IfPolicy.ETAG_EXACTLY, true, 206); + + testPreconditions(Task.GET_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, IfPolicy.DATE_EQ, true, 206); + testPreconditions(Task.GET_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, IfPolicy.DATE_LT, true, 200); + testPreconditions(Task.GET_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, IfPolicy.DATE_GT, true, 200); + + testPreconditions(Task.GET_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, IfPolicy.DATE_EQ, false, 200); + + // Test Range header is present, while if-range is not. + testPreconditions(Task.GET_INDEX_HTML, IfPolicy.ETAG_ALL, null, null, null, null, true, 206); + testPreconditions(Task.GET_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, null, null, null, true, 206); + testPreconditions(Task.GET_INDEX_HTML, IfPolicy.ETAG_IN, null, null, null, null, true, 206); + testPreconditions(Task.GET_INDEX_HTML, IfPolicy.ETAG_NOT_IN, null, null, null, null, true, 412); + testPreconditions(Task.GET_INDEX_HTML, IfPolicy.ETAG_SYNTAX_INVALID, null, null, null, null, true, 400); + } + + + @Test + public void testPreconditions2_2_1_post0() throws Exception { + startServer(true); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_ALL, null, null, null, null, 200); + } + + @Test + public void testPreconditions2_2_1_post1() throws Exception { + startServer(false); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_ALL, null, null, null, null, 200); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, null, null, null, 412); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_SYNTAX_INVALID, null, null, null, null, 400); + } + + @Test + public void testPreconditions2_2_2_post0() throws Exception { + startServer(true); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_LT, null, null, null, false, null, + k -> ((k >= 200 && k < 300) || k == 412), -1); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_MULTI_IN, null, null, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_SEMANTIC_INVALID, null, null, null, 200); + } + + @Test + public void testPreconditions2_2_2_post1() throws Exception { + startServer(false); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_LT, null, null, null, false, null, + k -> (k >= 200 && k < 300) || k == 412, -1); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_MULTI_IN, null, null, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_SEMANTIC_INVALID, null, null, null, 200); + } + + @Test + public void testPreconditions2_2_3_post0() throws Exception { + startServer(true); + testPreconditions(Task.POST_INDEX_HTML, null, null, IfPolicy.ETAG_NOT_IN, null, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, null, IfPolicy.ETAG_EXACTLY, null, null, 412); + testPreconditions(Task.POST_INDEX_HTML, null, null, IfPolicy.ETAG_ALL, null, null, 412); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_IN, null, IfPolicy.ETAG_NOT_IN, null, null, 200); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, IfPolicy.ETAG_EXACTLY, null, null, 412); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, IfPolicy.ETAG_ALL, null, null, 412); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_EQ, IfPolicy.ETAG_NOT_IN, null, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_GT, IfPolicy.ETAG_EXACTLY, null, null, 412); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_GT, IfPolicy.ETAG_ALL, null, null, 412); + } + + @Test + public void testPreconditions2_2_3_post1() throws Exception { + startServer(false); + testPreconditions(Task.POST_INDEX_HTML, null, null, IfPolicy.ETAG_NOT_IN, null, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, null, IfPolicy.ETAG_EXACTLY, null, null, 412); + testPreconditions(Task.POST_INDEX_HTML, null, null, IfPolicy.ETAG_ALL, null, null, 412); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_ALL, null, IfPolicy.ETAG_NOT_IN, null, null, 200); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, IfPolicy.ETAG_EXACTLY, null, null, 412); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, IfPolicy.ETAG_ALL, null, null, 412); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_EQ, IfPolicy.ETAG_NOT_IN, null, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_GT, IfPolicy.ETAG_EXACTLY, null, null, 412); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_GT, IfPolicy.ETAG_ALL, null, null, 412); + } + + @Test + public void testPreconditions2_2_4_post1() throws Exception { + startServer(false); + testPreconditions(Task.POST_INDEX_HTML, null, null, null, IfPolicy.DATE_EQ, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, null, null, IfPolicy.DATE_LT, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, null, null, IfPolicy.DATE_GT, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, null, null, IfPolicy.DATE_MULTI_IN, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, null, IfPolicy.ETAG_NOT_IN, IfPolicy.DATE_EQ, null, 200); + testPreconditions(Task.POST_INDEX_HTML, null, null, IfPolicy.ETAG_EXACTLY, IfPolicy.DATE_LT, null, 412); + testPreconditions(Task.POST_INDEX_HTML, null, null, IfPolicy.ETAG_ALL, IfPolicy.DATE_MULTI_IN, null, 412); + } + + @Test + public void testPreconditions2_2_5_post0() throws Exception { + startServer(true); + testPreconditions(Task.POST_INDEX_HTML, null, null, null, null, IfPolicy.DATE_EQ, true, 200); + // if-range: multiple node policy, not defined in RFC 9110. + // Currently, tomcat process the first If-Range header simply. + // testPreconditions(Task.GET_INDEX_HTML, null, null, null, null, IfPolicy.DATE_MULTI_IN, true,200); + testPreconditions(Task.POST_INDEX_HTML, null, null, null, null, IfPolicy.DATE_SEMANTIC_INVALID, true, 200); + testPreconditions(Task.POST_INDEX_HTML, null, null, null, null, IfPolicy.ETAG_EXACTLY, true, 200); + + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, IfPolicy.DATE_EQ, true, 200); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, IfPolicy.DATE_LT, true, 200); + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, IfPolicy.DATE_GT, true, 200); + + testPreconditions(Task.POST_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, IfPolicy.DATE_EQ, false, 200); + + // Test Range header is present, while if-range is not. + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_ALL, null, null, null, null, true, 200); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, null, null, null, true, 200); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_IN, null, null, null, null, true, 200); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_NOT_IN, null, null, null, null, true, 412); + testPreconditions(Task.POST_INDEX_HTML, IfPolicy.ETAG_SYNTAX_INVALID, null, null, null, null, true, 400); + } + + @Ignore + @Test + public void testPreconditions2_2_1_put0() throws Exception { + startServer(true); + testPreconditions(Task.PUT_EXIST_TXT, IfPolicy.ETAG_ALL, null, null, null, null, + HttpServletResponse.SC_NO_CONTENT); + testPreconditions(Task.PUT_EXIST_TXT, IfPolicy.ETAG_IN, null, null, null, null, + HttpServletResponse.SC_NO_CONTENT); + testPreconditions(Task.PUT_EXIST_TXT, IfPolicy.ETAG_NOT_IN, null, null, null, null, 412); + testPreconditions(Task.PUT_EXIST_TXT, IfPolicy.ETAG_SYNTAX_INVALID, null, null, null, null, 400); + + testPreconditions(Task.PUT_NEW_TXT, null, null, null, null, null, HttpServletResponse.SC_CREATED); + } + + @Ignore + @Test + public void testPreconditions2_2_1_put1() throws Exception { + startServer(false); + testPreconditions(Task.PUT_EXIST_TXT, IfPolicy.ETAG_ALL, null, null, null, null, + HttpServletResponse.SC_NO_CONTENT); + testPreconditions(Task.PUT_EXIST_TXT, IfPolicy.ETAG_SYNTAX_INVALID, null, null, null, null, 400); + testPreconditions(Task.PUT_EXIST_TXT, IfPolicy.ETAG_EXACTLY, null, null, null, null, 412); + } + + @Ignore + @Test + public void testPreconditions2_2_1_delete0() throws Exception { + startServer(true); + testPreconditions(Task.DELETE_EXIST1_TXT, IfPolicy.ETAG_ALL, null, null, null, null, + HttpServletResponse.SC_NO_CONTENT); + testPreconditions(Task.DELETE_EXIST2_TXT, IfPolicy.ETAG_IN, null, null, null, null, + HttpServletResponse.SC_NO_CONTENT); + testPreconditions(Task.DELETE_EXIST3_TXT, IfPolicy.ETAG_NOT_IN, null, null, null, null, 412); + testPreconditions(Task.DELETE_EXIST4_TXT, IfPolicy.ETAG_SYNTAX_INVALID, null, null, null, null, 400); + + testPreconditions(Task.DELETE_NOT_EXIST_TXT, null, null, null, null, null, 404); + } + + @Ignore + @Test + public void testPreconditions2_2_1_delete1() throws Exception { + startServer(false); + testPreconditions(Task.DELETE_EXIST1_TXT, IfPolicy.ETAG_ALL, null, null, null, null, + HttpServletResponse.SC_NO_CONTENT); + testPreconditions(Task.DELETE_EXIST3_TXT, IfPolicy.ETAG_EXACTLY, null, null, null, null, 412); + testPreconditions(Task.DELETE_EXIST2_TXT, IfPolicy.ETAG_SYNTAX_INVALID, null, null, null, null, 400); + } + + enum HTTP_METHOD { + GET, + PUT, + DELETE, + POST, + HEAD + } + + enum Task { + HEAD_INDEX_HTML(HTTP_METHOD.HEAD, "/index.html"), + HEAD_404_HTML(HTTP_METHOD.HEAD, "/sc_404.html"), + + GET_INDEX_HTML(HTTP_METHOD.GET, "/index.html"), + GET_404_HTML(HTTP_METHOD.GET, "/sc_404.html"), + + POST_INDEX_HTML(HTTP_METHOD.POST, "/index.html"), + POST_404_HTML(HTTP_METHOD.POST, "/sc_404.html"), + + PUT_EXIST_TXT(HTTP_METHOD.PUT, "/put_exist.txt"), + PUT_NEW_TXT(HTTP_METHOD.PUT, "/put_new.txt"), + + DELETE_EXIST_TXT(HTTP_METHOD.DELETE, "/delete_exist.txt"), + DELETE_EXIST1_TXT(HTTP_METHOD.DELETE, "/delete_exist1.txt"), + DELETE_EXIST2_TXT(HTTP_METHOD.DELETE, "/delete_exist2.txt"), + DELETE_EXIST3_TXT(HTTP_METHOD.DELETE, "/delete_exist3.txt"), + DELETE_EXIST4_TXT(HTTP_METHOD.DELETE, "/delete_exist4.txt"), + DELETE_NOT_EXIST_TXT(HTTP_METHOD.DELETE, "/delete_404.txt"); + + HTTP_METHOD m; + String uri; + + Task(HTTP_METHOD m, String uri) { + this.m = m; + this.uri = uri; + } + + @Override + public String toString() { + return m.name() + " " + uri; + } + } + + enum IfPolicy { + ETAG_EXACTLY, + ETAG_IN, + ETAG_ALL, + ETAG_NOT_IN, + ETAG_SYNTAX_INVALID, + /** + * Condition header value of http date is equivalent to actual resource lastModified date + */ + DATE_EQ, + /** + * Condition header value of http date is greater(later) than actual resource lastModified date + */ + DATE_GT, + /** + * Condition header value of http date is less(earlier) than actual resource lastModified date + */ + DATE_LT, + DATE_MULTI_IN, + /** + * not a valid HTTP-date + */ + DATE_SEMANTIC_INVALID; + } + + enum IfType { + ifMatch("If-Match"), // ETag strong comparison + ifUnmodifiedSince("If-Unmodified-Since"), + ifNoneMatch("If-None-Match"), // ETag weak comparison + ifModifiedSince("If-Modified-Since"), + ifRange("If-Range"); // ETag strong comparison + + private String header; + + IfType(String header) { + this.header = header; + } + + public String value() { + return this.header; + } + } + + protected List genETagCondtion(String strongETag, String weakETag, IfPolicy policy) { + List headerValues = new ArrayList(); + switch (policy) { + case ETAG_ALL: + headerValues.add("*"); + break; + case ETAG_EXACTLY: + if (strongETag != null) { + headerValues.add(strongETag); + } else { + // Should not happen + throw new IllegalArgumentException("strong etag not found!"); + } + break; + case ETAG_IN: + headerValues.add("\"1a2b3c4d\""); + headerValues.add(weakETag + "," + strongETag + ",W/\"*\""); + headerValues.add("\"abcdefg\""); + break; + case ETAG_NOT_IN: + if (weakETag != null && weakETag.length() > 8) { + headerValues.add(weakETag.substring(0, 3) + "XXXXX"+weakETag.substring(8)); + } + if (strongETag != null && strongETag.length() > 6) { + headerValues.add(strongETag.substring(0, 1) + "XXXXX"+strongETag.substring(6)); + } + break; + case ETAG_SYNTAX_INVALID: + headerValues.add("*"); + headerValues.add("W/\"1abcd\""); + break; + default: + break; + } + return headerValues; + } + + protected List genDateCondtion(long lastModifiedTimestamp, IfPolicy policy) { + List headerValues = new ArrayList(); + if (lastModifiedTimestamp <= 0) { + return headerValues; + } + switch (policy) { + case DATE_EQ: + headerValues.add(FastHttpDateFormat.formatDate(lastModifiedTimestamp)); + break; + case DATE_GT: + headerValues.add(FastHttpDateFormat.formatDate(lastModifiedTimestamp + 30000L)); + break; + case DATE_LT: + headerValues.add(FastHttpDateFormat.formatDate(lastModifiedTimestamp - 30000L)); + break; + case DATE_MULTI_IN: + headerValues.add(FastHttpDateFormat.formatDate(lastModifiedTimestamp - 30000L)); + headerValues.add(FastHttpDateFormat.formatDate(lastModifiedTimestamp)); + headerValues.add(FastHttpDateFormat.formatDate(lastModifiedTimestamp + 30000L)); + break; + case DATE_SEMANTIC_INVALID: + headerValues.add("2024.12.09 GMT"); + break; + default: + break; + } + return headerValues; + } + + protected void wrapperHeaders(Map> headers, String resourceETag, long lastModified, + IfPolicy policy, IfType type) { + Objects.requireNonNull(type); + if (policy == null) { + return; + } + List headerValues = new ArrayList(); + String weakETag = resourceETag; + String strongETag = resourceETag; + if (resourceETag != null) { + if (resourceETag.startsWith("W/")) { + strongETag = resourceETag.substring(2); + } else { + weakETag = "W/" + resourceETag; + } + } + + List eTagConditions = genETagCondtion(strongETag, weakETag, policy); + if (!eTagConditions.isEmpty()) { + headerValues.addAll(eTagConditions); + } + + List dateConditions = genDateCondtion(lastModified, policy); + if (!dateConditions.isEmpty()) { + headerValues.addAll(dateConditions); + } + + if (!headerValues.isEmpty()) { + headers.put(type.value(), headerValues); + } + } + + private File tempDocBase = null; + + @Override + public void setUp() throws Exception { + super.setUp(); + tempDocBase = Files.createTempDirectory(getTemporaryDirectory().toPath(), "conditional").toFile(); + long lastModified = FastHttpDateFormat.parseDate("Fri, 06 Dec 2024 00:00:00 GMT"); + Files.write(Path.of(tempDocBase.getAbsolutePath(), "index.html"), "Index".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "index.html").toFile().setLastModified(lastModified); + + Files.write(Path.of(tempDocBase.getAbsolutePath(), "put_exist.txt"), "put_exist_v0".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "put_exist.txt").toFile().setLastModified(lastModified); + + Files.write(Path.of(tempDocBase.getAbsolutePath(), "delete_exist.txt"), "delete_exist_v0".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "delete_exist.txt").toFile().setLastModified(lastModified); + + Files.write(Path.of(tempDocBase.getAbsolutePath(), "delete_exist1.txt"), "delete_exist1_v0".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "delete_exist1.txt").toFile().setLastModified(lastModified); + + Files.write(Path.of(tempDocBase.getAbsolutePath(), "delete_exist2.txt"), "delete_exist2_v0".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "delete_exist2.txt").toFile().setLastModified(lastModified); + + Files.write(Path.of(tempDocBase.getAbsolutePath(), "delete_exist3.txt"), "delete_exist3_v0".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "delete_exist3.txt").toFile().setLastModified(lastModified); + + Files.write(Path.of(tempDocBase.getAbsolutePath(), "delete_exist4.txt"), "delete_exist4_v0".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "delete_exist4.txt").toFile().setLastModified(lastModified); + + } + + protected void startServer(boolean resourceHasStrongETag) throws Exception { + Tomcat tomcat = getTomcatInstance(); + Context ctxt = tomcat.addContext("", tempDocBase.getAbsolutePath()); + + Wrapper w = Tomcat.addServlet(ctxt, "default", DefaultServlet.class.getName()); + w.addInitParameter("readonly", "false"); + w.addInitParameter("allowPartialPut", Boolean.toString(true)); + w.addInitParameter("useStrongETags", Boolean.toString(resourceHasStrongETag)); + ctxt.addServletMappingDecoded("/", "default"); + + tomcat.start(); + } + + + protected void testPreconditions(Task task, IfPolicy ifMatchHeader, IfPolicy ifUnmodifiedSinceHeader, + IfPolicy ifNoneMatchHeader, IfPolicy ifModifiedSinceHeader, IfPolicy ifRangeHeader, boolean autoRangeHeader, + String message, IntPredicate p, int... scExpected) throws Exception { + Assert.assertNotNull(task); + + + Map> requestHeaders = new HashMap<>(); + + Map> responseHeaders = new HashMap<>(); + + String etag = null; + long lastModified = -1; + String uri = "http://localhost:" + getPort() + task.uri; + // Try head to receives etag and lastModified Date + int sc = headUrl(uri, new ByteChunk(), responseHeaders); + if (sc == 200) { + etag = getSingleHeader("ETag", responseHeaders); + String dt = getSingleHeader("Last-Modified", responseHeaders); + if (dt != null && dt.length() > 0) { + lastModified = FastHttpDateFormat.parseDate(dt); + } + } + + wrapperHeaders(requestHeaders, etag, lastModified, ifMatchHeader, IfType.ifMatch); + wrapperHeaders(requestHeaders, etag, lastModified, ifModifiedSinceHeader, IfType.ifModifiedSince); + wrapperHeaders(requestHeaders, etag, lastModified, ifNoneMatchHeader, IfType.ifNoneMatch); + wrapperHeaders(requestHeaders, etag, lastModified, ifUnmodifiedSinceHeader, IfType.ifUnmodifiedSince); + wrapperHeaders(requestHeaders, etag, lastModified, ifRangeHeader, IfType.ifRange); + responseHeaders.clear(); + sc = 0; + SimpleHttpClient client = null; + client = new SimpleHttpClient() { + + @Override + public boolean isResponseBodyOK() { + return true; + } + }; + client.setPort(getPort()); + StringBuffer curl = new StringBuffer(); + curl.append(task.m.name() + " " + task.uri + " HTTP/1.1" + SimpleHttpClient.CRLF + "Host: localhost" + SimpleHttpClient.CRLF + + "Connection: Close" + SimpleHttpClient.CRLF); + + for (Entry> e : requestHeaders.entrySet()) { + for (String v : e.getValue()) { + curl.append(e.getKey() + ": " + v + SimpleHttpClient.CRLF); + } + } + if (autoRangeHeader) { + curl.append("Range: bytes=0-10" + SimpleHttpClient.CRLF); + } + curl.append("Content-Length: 6" + SimpleHttpClient.CRLF); + curl.append(SimpleHttpClient.CRLF); + + curl.append("PUT_v2"); + client.setRequest(new String[] { curl.toString() }); + client.connect(); + client.processRequest(); + for (String e : client.getResponseHeaders()) { + Assert.assertTrue("Separator ':' expected and not the last char of response header field `" + e + "`", + e.contains(":") && e.indexOf(':') < e.length() - 1); + String name = e.substring(0, e.indexOf(':')); + String value = e.substring(e.indexOf(':') + 1); + responseHeaders.computeIfAbsent(name, k -> new ArrayList()).add(value); + } + sc = client.getStatusCode(); + if (message == null) { + message = "Unexpected status code:`" + sc + "`"; + } + boolean test = false; + boolean usePredicate = false; + if (scExpected != null && scExpected.length > 0 && scExpected[0] >= 100) { + test = Arrays.binarySearch(scExpected, sc) >= 0; + } else { + usePredicate = true; + test = p.test(sc); + } + String scExpectation = usePredicate ? "IntPredicate" : Arrays.toString(scExpected); + Assert.assertTrue( + "Failure - sc expected:%s, sc actual:%d, %s, task:%s, \ntarget resource:(%s,%s), \nreq headers: %s, \nresp headers: %s" + .formatted(scExpectation, sc, message, task, etag, FastHttpDateFormat.formatDate(lastModified), + requestHeaders.toString(), responseHeaders.toString()), + test); + } + + protected void testPreconditions(Task task, IfPolicy ifMatchHeader, IfPolicy ifUnmodifiedSinceHeader, + IfPolicy ifNoneMatchHeader, IfPolicy ifModifiedSinceHeader, IfPolicy ifRangeHeader, int... scExpected) + throws Exception { + testPreconditions(task, ifMatchHeader, ifUnmodifiedSinceHeader, ifNoneMatchHeader, ifModifiedSinceHeader, + ifRangeHeader, false, scExpected); + } + + protected void testPreconditions(Task task, IfPolicy ifMatchHeader, IfPolicy ifUnmodifiedSinceHeader, + IfPolicy ifNoneMatchHeader, IfPolicy ifModifiedSinceHeader, IfPolicy ifRangeHeader, boolean autoRangeHeader, + int... scExpected) throws Exception { + testPreconditions(task, ifMatchHeader, ifUnmodifiedSinceHeader, ifNoneMatchHeader, ifModifiedSinceHeader, + ifRangeHeader, autoRangeHeader, null, null, scExpected); + } +} diff --git a/test/org/apache/catalina/servlets/TestDefaultServletRfc9110Section13Parameterized.java b/test/org/apache/catalina/servlets/TestDefaultServletRfc9110Section13Parameterized.java new file mode 100644 index 000000000000..192b9ffc75a6 --- /dev/null +++ b/test/org/apache/catalina/servlets/TestDefaultServletRfc9110Section13Parameterized.java @@ -0,0 +1,433 @@ +/* + * 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.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.function.IntPredicate; + +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.Wrapper; +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; +import org.apache.tomcat.util.http.FastHttpDateFormat; + +/** + * This test case is used to verify RFC 9110 Section 13. Conditional Requests. + */ +@RunWith(Parameterized.class) +public class TestDefaultServletRfc9110Section13Parameterized extends TomcatBaseTest { + @Parameter(0) + public boolean useStrongETags; + @Parameter(1) + public Task task; + @Parameter(2) + public IfPolicy ifMatchHeader; + @Parameter(3) + public IfPolicy ifUnmodifiedSinceHeader; + @Parameter(4) + public IfPolicy ifNoneMatchHeader; + @Parameter(5) + public IfPolicy ifModifiedSinceHeader; + @Parameter(6) + public IfPolicy ifRangeHeader; + @Parameter(7) + public boolean autoRangeHeader; + @Parameter(8) + public IntPredicate p; + @Parameter(9) + public int[] scExpected; + + @Parameterized.Parameters(name = "{index} resource-strong [{0}], matchHeader [{1}]") + public static Collection parameters() { + List parameterSets = new ArrayList<>(); + // testPreconditions_rfc9110_13_2_2_1_head0 + parameterSets.add(new Object[] { true, Task.HEAD_INDEX_HTML, IfPolicy.ETAG_ALL, null, null, null, null, false, + null, new int[] { 200 } }); + parameterSets.add(new Object[] { true, Task.HEAD_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, null, null, null, + false, null, new int[] { 200 } }); + parameterSets.add(new Object[] { true, Task.HEAD_INDEX_HTML, IfPolicy.ETAG_IN, null, null, null, null, false, + null, new int[] { 200 } }); + parameterSets.add(new Object[] { true, Task.HEAD_INDEX_HTML, IfPolicy.ETAG_NOT_IN, null, null, null, null, + false, null, new int[] { 412 } }); + parameterSets.add(new Object[] { true, Task.HEAD_INDEX_HTML, IfPolicy.ETAG_SYNTAX_INVALID, null, null, null, + null, false, null, new int[] { 400 } }); + + parameterSets.add(new Object[] { false, Task.HEAD_INDEX_HTML, IfPolicy.ETAG_ALL, null, null, null, null, false, + null, new int[] { 200 } }); + parameterSets.add(new Object[] { false, Task.HEAD_INDEX_HTML, IfPolicy.ETAG_EXACTLY, null, null, null, null, + false, null, new int[] { 412 } }); + parameterSets.add(new Object[] { false, Task.HEAD_INDEX_HTML, IfPolicy.ETAG_IN, null, null, null, null, false, + null, new int[] { 412 } }); + parameterSets.add(new Object[] { false, Task.HEAD_INDEX_HTML, IfPolicy.ETAG_NOT_IN, null, null, null, null, + false, null, new int[] { 412 } }); + parameterSets.add(new Object[] { false, Task.HEAD_INDEX_HTML, IfPolicy.ETAG_SYNTAX_INVALID, null, null, null, + null, false, null, new int[] { 400 } }); + + parameterSets.add(new Object[] { true, Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, null, false, + null, new int[] { 200 } }); + parameterSets.add(new Object[] { true, Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_LT, null, null, null, false, + null, new int[] { 412 } }); + parameterSets.add(new Object[] { true, Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_GT, null, null, null, false, + null, new int[] { 200 } }); + parameterSets.add(new Object[] { true, Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_MULTI_IN, null, null, null, + false, null, new int[] { 200 } }); + + parameterSets.add(new Object[] { false, Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_EQ, null, null, null, false, + null, new int[] { 200 } }); + parameterSets.add(new Object[] { false, Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_LT, null, null, null, false, + null, new int[] { 412 } }); + parameterSets.add(new Object[] { false, Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_GT, null, null, null, false, + null, new int[] { 200 } }); + parameterSets.add(new Object[] { false, Task.HEAD_INDEX_HTML, null, IfPolicy.DATE_MULTI_IN, null, null, null, + false, null, new int[] { 200 } }); + + + return parameterSets; + } + + + enum HTTP_METHOD { + GET, + PUT, + DELETE, + POST, + HEAD + } + + enum Task { + HEAD_INDEX_HTML(HTTP_METHOD.HEAD, "/index.html"), + HEAD_404_HTML(HTTP_METHOD.HEAD, "/sc_404.html"), + + GET_INDEX_HTML(HTTP_METHOD.GET, "/index.html"), + GET_404_HTML(HTTP_METHOD.GET, "/sc_404.html"), + + POST_INDEX_HTML(HTTP_METHOD.POST, "/index.html"), + POST_404_HTML(HTTP_METHOD.POST, "/sc_404.html"), + + PUT_EXIST_TXT(HTTP_METHOD.PUT, "/put_exist.txt"), + PUT_NEW_TXT(HTTP_METHOD.PUT, "/put_new.txt"), + + DELETE_EXIST_TXT(HTTP_METHOD.DELETE, "/delete_exist.txt"), + DELETE_EXIST1_TXT(HTTP_METHOD.DELETE, "/delete_exist1.txt"), + DELETE_EXIST2_TXT(HTTP_METHOD.DELETE, "/delete_exist2.txt"), + DELETE_EXIST3_TXT(HTTP_METHOD.DELETE, "/delete_exist3.txt"), + DELETE_EXIST4_TXT(HTTP_METHOD.DELETE, "/delete_exist4.txt"), + DELETE_NOT_EXIST_TXT(HTTP_METHOD.DELETE, "/delete_404.txt"); + + HTTP_METHOD m; + String uri; + + Task(HTTP_METHOD m, String uri) { + this.m = m; + this.uri = uri; + } + + @Override + public String toString() { + return m.name() + " " + uri; + } + } + + enum IfPolicy { + ETAG_EXACTLY, + ETAG_IN, + ETAG_ALL, + ETAG_NOT_IN, + ETAG_SYNTAX_INVALID, + /** + * Condition header value of http date is equivalent to actual resource lastModified date + */ + DATE_EQ, + /** + * Condition header value of http date is greater(later) than actual resource lastModified date + */ + DATE_GT, + /** + * Condition header value of http date is less(earlier) than actual resource lastModified date + */ + DATE_LT, + DATE_MULTI_IN, + /** + * not a valid HTTP-date + */ + DATE_SEMANTIC_INVALID; + } + + enum IfType { + ifMatch("If-Match"), // ETag strong comparison + ifUnmodifiedSince("If-Unmodified-Since"), + ifNoneMatch("If-None-Match"), // ETag weak comparison + ifModifiedSince("If-Modified-Since"), + ifRange("If-Range"); // ETag strong comparison + + private String header; + + IfType(String header) { + this.header = header; + } + + public String value() { + return this.header; + } + } + + protected List genETagCondtion(String strongETag, String weakETag, IfPolicy policy) { + List headerValues = new ArrayList(); + switch (policy) { + case ETAG_ALL: + headerValues.add("*"); + break; + case ETAG_EXACTLY: + if (strongETag != null) { + headerValues.add(strongETag); + } else { + // Should not happen + throw new IllegalArgumentException("strong etag not found!"); + } + break; + case ETAG_IN: + headerValues.add("\"1a2b3c4d\""); + headerValues.add(weakETag + "," + strongETag + ",W/\"*\""); + headerValues.add("\"abcdefg\""); + break; + case ETAG_NOT_IN: + if (weakETag != null && weakETag.length() > 8) { + headerValues.add(weakETag.substring(0, 3) + "XXXXX" + weakETag.substring(8)); + } + if (strongETag != null && strongETag.length() > 6) { + headerValues.add(strongETag.substring(0, 1) + "XXXXX" + strongETag.substring(6)); + } + break; + case ETAG_SYNTAX_INVALID: + headerValues.add("*"); + headerValues.add("W/\"1abcd\""); + break; + default: + break; + } + return headerValues; + } + + protected List genDateCondtion(long lastModifiedTimestamp, IfPolicy policy) { + List headerValues = new ArrayList(); + if (lastModifiedTimestamp <= 0) { + return headerValues; + } + switch (policy) { + case DATE_EQ: + headerValues.add(FastHttpDateFormat.formatDate(lastModifiedTimestamp)); + break; + case DATE_GT: + headerValues.add(FastHttpDateFormat.formatDate(lastModifiedTimestamp + 30000L)); + break; + case DATE_LT: + headerValues.add(FastHttpDateFormat.formatDate(lastModifiedTimestamp - 30000L)); + break; + case DATE_MULTI_IN: + headerValues.add(FastHttpDateFormat.formatDate(lastModifiedTimestamp - 30000L)); + headerValues.add(FastHttpDateFormat.formatDate(lastModifiedTimestamp)); + headerValues.add(FastHttpDateFormat.formatDate(lastModifiedTimestamp + 30000L)); + break; + case DATE_SEMANTIC_INVALID: + headerValues.add("2024.12.09 GMT"); + break; + default: + break; + } + return headerValues; + } + + protected void wrapperHeaders(Map> headers, String resourceETag, long lastModified, + IfPolicy policy, IfType type) { + Objects.requireNonNull(type); + if (policy == null) { + return; + } + List headerValues = new ArrayList(); + String weakETag = resourceETag; + String strongETag = resourceETag; + if (resourceETag != null) { + if (resourceETag.startsWith("W/")) { + strongETag = resourceETag.substring(2); + } else { + weakETag = "W/" + resourceETag; + } + } + + List eTagConditions = genETagCondtion(strongETag, weakETag, policy); + if (!eTagConditions.isEmpty()) { + headerValues.addAll(eTagConditions); + } + + List dateConditions = genDateCondtion(lastModified, policy); + if (!dateConditions.isEmpty()) { + headerValues.addAll(dateConditions); + } + + if (!headerValues.isEmpty()) { + headers.put(type.value(), headerValues); + } + } + + private File tempDocBase = null; + + @Override + public void setUp() throws Exception { + super.setUp(); + tempDocBase = Files.createTempDirectory(getTemporaryDirectory().toPath(), "conditional").toFile(); + long lastModified = FastHttpDateFormat.parseDate("Fri, 06 Dec 2024 00:00:00 GMT"); + Files.write(Path.of(tempDocBase.getAbsolutePath(), "index.html"), "Index".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "index.html").toFile().setLastModified(lastModified); + + Files.write(Path.of(tempDocBase.getAbsolutePath(), "put_exist.txt"), "put_exist_v0".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "put_exist.txt").toFile().setLastModified(lastModified); + + Files.write(Path.of(tempDocBase.getAbsolutePath(), "delete_exist.txt"), "delete_exist_v0".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "delete_exist.txt").toFile().setLastModified(lastModified); + + Files.write(Path.of(tempDocBase.getAbsolutePath(), "delete_exist1.txt"), "delete_exist1_v0".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "delete_exist1.txt").toFile().setLastModified(lastModified); + + Files.write(Path.of(tempDocBase.getAbsolutePath(), "delete_exist2.txt"), "delete_exist2_v0".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "delete_exist2.txt").toFile().setLastModified(lastModified); + + Files.write(Path.of(tempDocBase.getAbsolutePath(), "delete_exist3.txt"), "delete_exist3_v0".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "delete_exist3.txt").toFile().setLastModified(lastModified); + + Files.write(Path.of(tempDocBase.getAbsolutePath(), "delete_exist4.txt"), "delete_exist4_v0".getBytes(), + StandardOpenOption.CREATE); + Path.of(tempDocBase.getAbsolutePath(), "delete_exist4.txt").toFile().setLastModified(lastModified); + + } + + @Test + public void testPreconditions() throws Exception { + Tomcat tomcat = getTomcatInstance(); + Context ctxt = tomcat.addContext("", tempDocBase.getAbsolutePath()); + + Wrapper w = Tomcat.addServlet(ctxt, "default", DefaultServlet.class.getName()); + w.addInitParameter("readonly", "false"); + w.addInitParameter("allowPartialPut", Boolean.toString(true)); + w.addInitParameter("useStrongETags", Boolean.toString(useStrongETags)); + ctxt.addServletMappingDecoded("/", "default"); + + tomcat.start(); + + Assert.assertNotNull(task); + + + Map> requestHeaders = new HashMap<>(); + + Map> responseHeaders = new HashMap<>(); + + String etag = null; + long lastModified = -1; + String uri = "http://localhost:" + getPort() + task.uri; + // Try head to receives etag and lastModified Date + int sc = headUrl(uri, new ByteChunk(), responseHeaders); + if (sc == 200) { + etag = getSingleHeader("ETag", responseHeaders); + String dt = getSingleHeader("Last-Modified", responseHeaders); + if (dt != null && dt.length() > 0) { + lastModified = FastHttpDateFormat.parseDate(dt); + } + } + + wrapperHeaders(requestHeaders, etag, lastModified, ifMatchHeader, IfType.ifMatch); + wrapperHeaders(requestHeaders, etag, lastModified, ifModifiedSinceHeader, IfType.ifModifiedSince); + wrapperHeaders(requestHeaders, etag, lastModified, ifNoneMatchHeader, IfType.ifNoneMatch); + wrapperHeaders(requestHeaders, etag, lastModified, ifUnmodifiedSinceHeader, IfType.ifUnmodifiedSince); + wrapperHeaders(requestHeaders, etag, lastModified, ifRangeHeader, IfType.ifRange); + responseHeaders.clear(); + sc = 0; + SimpleHttpClient client = null; + client = new SimpleHttpClient() { + + @Override + public boolean isResponseBodyOK() { + return true; + } + }; + client.setPort(getPort()); + StringBuffer curl = new StringBuffer(); + curl.append(task.m.name() + " " + task.uri + " HTTP/1.1" + SimpleHttpClient.CRLF + "Host: localhost" + + SimpleHttpClient.CRLF + "Connection: Close" + SimpleHttpClient.CRLF); + + for (Entry> e : requestHeaders.entrySet()) { + for (String v : e.getValue()) { + curl.append(e.getKey() + ": " + v + SimpleHttpClient.CRLF); + } + } + if (autoRangeHeader) { + curl.append("Range: bytes=0-10" + SimpleHttpClient.CRLF); + } + curl.append("Content-Length: 6" + SimpleHttpClient.CRLF); + curl.append(SimpleHttpClient.CRLF); + + curl.append("PUT_v2"); + client.setRequest(new String[] { curl.toString() }); + client.connect(); + client.processRequest(); + for (String e : client.getResponseHeaders()) { + Assert.assertTrue("Separator ':' expected and not the last char of response header field `" + e + "`", + e.contains(":") && e.indexOf(':') < e.length() - 1); + String name = e.substring(0, e.indexOf(':')); + String value = e.substring(e.indexOf(':') + 1); + responseHeaders.computeIfAbsent(name, k -> new ArrayList()).add(value); + } + sc = client.getStatusCode(); + boolean test = false; + boolean usePredicate = false; + if (scExpected != null && scExpected.length > 0 && scExpected[0] >= 100) { + test = Arrays.binarySearch(scExpected, sc) >= 0; + } else { + usePredicate = true; + test = p.test(sc); + } + String scExpectation = usePredicate ? "IntPredicate" : Arrays.toString(scExpected); + Assert.assertTrue( + "Failure - sc expected:%s, sc actual:%d, task:%s, \ntarget resource:(%s,%s), \nreq headers: %s, \nresp headers: %s" + .formatted(scExpectation, sc, task, etag, FastHttpDateFormat.formatDate(lastModified), + requestHeaders.toString(), responseHeaders.toString()), + test); + } +} diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index daa677ff46a4..7edc0d6391c2 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -112,6 +112,10 @@ DataSourcePropertyStore that may be used by the WebDAV Servlet. (remm) + + Improve HTTP If headers processing according to RFC 9110. Based on pull + request 796 by Chenjp. (remm) +