Skip to content

Commit

Permalink
Efficient ETag parsing
Browse files Browse the repository at this point in the history
Closes gh-33372
  • Loading branch information
rstoyanchev committed Aug 14, 2024
1 parent 406b33d commit 582bfcc
Show file tree
Hide file tree
Showing 3 changed files with 180 additions and 43 deletions.
161 changes: 161 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,161 @@
/*
* 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.
*
* @author Rossen Stoyanchev
* @since 5.3.38
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7232">RFC 7232</a>
*/
public class ETag {

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

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


private final String tag;

private final boolean weak;


public ETag(String tag, boolean weak) {
this.tag = tag;
this.weak = weak;
}


public String tag() {
return this.tag;
}

public boolean weak() {
return this.weak;
}

/**
* 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 (this == WILDCARD) {
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 @@ -40,8 +40,6 @@
import java.util.Map;
import java.util.Set;
import java.util.StringJoiner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.springframework.lang.Nullable;
Expand Down Expand Up @@ -393,12 +391,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 @@ -1568,35 +1560,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
@@ -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 All @@ -26,13 +26,12 @@
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.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 @@ -54,12 +53,6 @@ public class ServletWebRequest extends ServletRequestAttributes implements Nativ

private static final List<String> SAFE_METHODS = Arrays.asList("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 @@ -289,11 +282,10 @@ private boolean validateIfNoneMatch(@Nullable String etag) {
etag = etag.substring(2);
}
while (ifNoneMatch.hasMoreElements()) {
String clientETags = ifNoneMatch.nextElement();
Matcher etagMatcher = ETAG_HEADER_VALUE_PATTERN.matcher(clientETags);
// Compare weak/strong ETags as per https://tools.ietf.org/html/rfc7232#section-2.3
while (etagMatcher.find()) {
if (StringUtils.hasLength(etagMatcher.group()) && etag.equals(etagMatcher.group(3))) {
for (ETag requestedETag : ETag.parse(ifNoneMatch.nextElement())) {
String tag = requestedETag.tag();
if (StringUtils.hasLength(tag) && etag.equals(padEtagIfNecessary(tag))) {
this.notModified = true;
break;
}
Expand Down

0 comments on commit 582bfcc

Please sign in to comment.