Skip to content

Commit

Permalink
Efficient ETag parsing
Browse files Browse the repository at this point in the history
  • Loading branch information
rstoyanchev committed Aug 14, 2024
1 parent 63486bf commit bb17ad8
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 45 deletions.
144 changes: 144 additions & 0 deletions spring-web/src/main/java/org/springframework/http/ETag.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed 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
*
* https://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.springframework.http;

import java.util.ArrayList;
import java.util.List;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.util.StringUtils;

/**
* Represents an ETag for HTTP conditional requests.
*
* @param tag the unquoted tag value
* @param weak whether the entity tag is for weak or strong validation
* @author Rossen Stoyanchev

This comment has been minimized.

Copy link
@kashike

This comment has been minimized.

Copy link
@sbrannen

sbrannen Aug 18, 2024

Member

Good catch.

The order of Javadoc tags for records does not get verified by our Checkstyle rules at the moment.

I'll address that in #33403.

* @since 5.3.38
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7232">RFC 7232</a>
*/
public record ETag(String tag, boolean weak) {

private static final Log logger = LogFactory.getLog(ETag.class);

private static final ETag WILDCARD = new ETag("*", false);


/**
* Whether this a wildcard tag matching to any entity tag value.
*/
public boolean isWildcard() {
return (this == WILDCARD);
}

/**
* Return the fully formatted tag including "W/" prefix and quotes.
*/
public String formattedTag() {
if (isWildcard()) {
return "*";
}
return (this.weak ? "W/" : "") + "\"" + this.tag + "\"";
}

@Override
public String toString() {
return formattedTag();
}


/**
* Parse entity tags from an "If-Match" or "If-None-Match" header.
* @param source the source string to parse
* @return the parsed ETags
*/
public static List<ETag> parse(String source) {

List<ETag> result = new ArrayList<>();
State state = State.BEFORE_QUOTES;
int startIndex = -1;
boolean weak = false;

for (int i = 0; i < source.length(); i++) {
char c = source.charAt(i);

if (state == State.IN_QUOTES) {
if (c == '"') {
String tag = source.substring(startIndex, i);
if (StringUtils.hasText(tag)) {
result.add(new ETag(tag, weak));
}
state = State.AFTER_QUOTES;
startIndex = -1;
weak = false;
}
continue;
}

if (Character.isWhitespace(c)) {
continue;
}

if (c == ',') {
state = State.BEFORE_QUOTES;
continue;
}

if (state == State.BEFORE_QUOTES) {
if (c == '*') {
result.add(WILDCARD);
state = State.AFTER_QUOTES;
continue;
}
if (c == '"') {
state = State.IN_QUOTES;
startIndex = i + 1;
continue;
}
if (c == 'W' && source.length() > i + 2) {
if (source.charAt(i + 1) == '/' && source.charAt(i + 2) == '"') {
state = State.IN_QUOTES;
i = i + 2;
startIndex = i + 1;
weak = true;
continue;
}
}
}

if (logger.isDebugEnabled()) {
logger.debug("Unexpected char at index " + i);
}
}

if (state != State.IN_QUOTES && logger.isDebugEnabled()) {
logger.debug("Expected closing '\"'");
}

return result;
}


private enum State {

BEFORE_QUOTES, IN_QUOTES, AFTER_QUOTES

}

}
44 changes: 14 additions & 30 deletions spring-web/src/main/java/org/springframework/http/HttpHeaders.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@
import java.util.Set;
import java.util.StringJoiner;
import java.util.function.BiConsumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.springframework.lang.Nullable;
Expand Down Expand Up @@ -394,12 +392,6 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
*/
public static final HttpHeaders EMPTY = new ReadOnlyHttpHeaders(new LinkedMultiValueMap<>());

/**
* Pattern matching ETag multiple field values in headers such as "If-Match", "If-None-Match".
* @see <a href="https://tools.ietf.org/html/rfc7232#section-2.3">Section 2.3 of RFC 7232</a>
*/
private static final Pattern ETAG_HEADER_VALUE_PATTERN = Pattern.compile("\\*|\\s*((W\\/)?(\"[^\"]*\"))\\s*,?");

private static final DecimalFormatSymbols DECIMAL_FORMAT_SYMBOLS = new DecimalFormatSymbols(Locale.ENGLISH);

private static final ZoneId GMT = ZoneId.of("GMT");
Expand Down Expand Up @@ -1629,35 +1621,27 @@ public void clearContentHeaders() {

/**
* Retrieve a combined result from the field values of the ETag header.
* @param headerName the header name
* @param name the header name
* @return the combined result
* @throws IllegalArgumentException if parsing fails
* @since 4.3
*/
protected List<String> getETagValuesAsList(String headerName) {
List<String> values = get(headerName);
if (values != null) {
List<String> result = new ArrayList<>();
for (String value : values) {
if (value != null) {
Matcher matcher = ETAG_HEADER_VALUE_PATTERN.matcher(value);
while (matcher.find()) {
if ("*".equals(matcher.group())) {
result.add(matcher.group());
}
else {
result.add(matcher.group(1));
}
}
if (result.isEmpty()) {
throw new IllegalArgumentException(
"Could not parse header '" + headerName + "' with value '" + value + "'");
}
protected List<String> getETagValuesAsList(String name) {
List<String> values = get(name);
if (values == null) {
return Collections.emptyList();
}
List<String> result = new ArrayList<>();
for (String value : values) {
if (value != null) {
List<ETag> tags = ETag.parse(value);
Assert.notEmpty(tags, "Could not parse header '" + name + "' with value '" + value + "'");
for (ETag tag : tags) {
result.add(tag.formattedTag());
}
}
return result;
}
return Collections.emptyList();
return result;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,12 @@
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;

import org.springframework.http.ETag;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
Expand All @@ -53,12 +52,6 @@ public class ServletWebRequest extends ServletRequestAttributes implements Nativ

private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD");

/**
* Pattern matching ETag multiple field values in headers such as "If-Match", "If-None-Match".
* @see <a href="https://tools.ietf.org/html/rfc7232#section-2.3">Section 2.3 of RFC 7232</a>
*/
private static final Pattern ETAG_HEADER_VALUE_PATTERN = Pattern.compile("\\*|\\s*((W\\/)?(\"[^\"]*\"))\\s*,?");

/**
* Date formats as specified in the HTTP RFC.
* @see <a href="https://tools.ietf.org/html/rfc7231#section-7.1.1.1">Section 7.1.1.1 of RFC 7231</a>
Expand Down Expand Up @@ -255,20 +248,19 @@ private boolean matchRequestedETags(Enumeration<String> requestedETags, @Nullabl
etag = padEtagIfNecessary(etag);
while (requestedETags.hasMoreElements()) {
// Compare weak/strong ETags as per https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.3
Matcher etagMatcher = ETAG_HEADER_VALUE_PATTERN.matcher(requestedETags.nextElement());
while (etagMatcher.find()) {
for (ETag requestedETag : ETag.parse(requestedETags.nextElement())) {
// only consider "lost updates" checks for unsafe HTTP methods
if ("*".equals(etagMatcher.group()) && StringUtils.hasLength(etag)
if (requestedETag.isWildcard() && StringUtils.hasLength(etag)
&& !SAFE_METHODS.contains(getRequest().getMethod())) {
return false;
}
if (weakCompare) {
if (etagWeakMatch(etag, etagMatcher.group(1))) {
if (etagWeakMatch(etag, requestedETag.formattedTag())) {
return false;
}
}
else {
if (etagStrongMatch(etag, etagMatcher.group(1))) {
if (etagStrongMatch(etag, requestedETag.formattedTag())) {
return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -163,8 +163,8 @@ void ifNoneMatchShouldNotMatchDifferentETag(String method) {
assertOkWithETag(etag);
}

// gh-19127
@SafeHttpMethodsTest
// SPR-14559
void ifNoneMatchShouldNotFailForUnquotedETag(String method) {
setUpRequest(method);
String etag = "\"etagvalue\"";
Expand Down

0 comments on commit bb17ad8

Please sign in to comment.