Skip to content

Commit 582bfcc

Browse files
committed
Efficient ETag parsing
Closes gh-33372
1 parent 406b33d commit 582bfcc

File tree

3 files changed

+180
-43
lines changed

3 files changed

+180
-43
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.http;
18+
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
22+
import org.apache.commons.logging.Log;
23+
import org.apache.commons.logging.LogFactory;
24+
25+
import org.springframework.util.StringUtils;
26+
27+
/**
28+
* Represents an ETag for HTTP conditional requests.
29+
*
30+
* @author Rossen Stoyanchev
31+
* @since 5.3.38
32+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7232">RFC 7232</a>
33+
*/
34+
public class ETag {
35+
36+
private static final Log logger = LogFactory.getLog(ETag.class);
37+
38+
private static final ETag WILDCARD = new ETag("*", false);
39+
40+
41+
private final String tag;
42+
43+
private final boolean weak;
44+
45+
46+
public ETag(String tag, boolean weak) {
47+
this.tag = tag;
48+
this.weak = weak;
49+
}
50+
51+
52+
public String tag() {
53+
return this.tag;
54+
}
55+
56+
public boolean weak() {
57+
return this.weak;
58+
}
59+
60+
/**
61+
* Whether this a wildcard tag matching to any entity tag value.
62+
*/
63+
public boolean isWildcard() {
64+
return (this == WILDCARD);
65+
}
66+
67+
/**
68+
* Return the fully formatted tag including "W/" prefix and quotes.
69+
*/
70+
public String formattedTag() {
71+
if (this == WILDCARD) {
72+
return "*";
73+
}
74+
return (this.weak ? "W/" : "") + "\"" + this.tag + "\"";
75+
}
76+
77+
@Override
78+
public String toString() {
79+
return formattedTag();
80+
}
81+
82+
83+
/**
84+
* Parse entity tags from an "If-Match" or "If-None-Match" header.
85+
* @param source the source string to parse
86+
* @return the parsed ETags
87+
*/
88+
public static List<ETag> parse(String source) {
89+
90+
List<ETag> result = new ArrayList<>();
91+
State state = State.BEFORE_QUOTES;
92+
int startIndex = -1;
93+
boolean weak = false;
94+
95+
for (int i = 0; i < source.length(); i++) {
96+
char c = source.charAt(i);
97+
98+
if (state == State.IN_QUOTES) {
99+
if (c == '"') {
100+
String tag = source.substring(startIndex, i);
101+
if (StringUtils.hasText(tag)) {
102+
result.add(new ETag(tag, weak));
103+
}
104+
state = State.AFTER_QUOTES;
105+
startIndex = -1;
106+
weak = false;
107+
}
108+
continue;
109+
}
110+
111+
if (Character.isWhitespace(c)) {
112+
continue;
113+
}
114+
115+
if (c == ',') {
116+
state = State.BEFORE_QUOTES;
117+
continue;
118+
}
119+
120+
if (state == State.BEFORE_QUOTES) {
121+
if (c == '*') {
122+
result.add(WILDCARD);
123+
state = State.AFTER_QUOTES;
124+
continue;
125+
}
126+
if (c == '"') {
127+
state = State.IN_QUOTES;
128+
startIndex = i + 1;
129+
continue;
130+
}
131+
if (c == 'W' && source.length() > i + 2) {
132+
if (source.charAt(i + 1) == '/' && source.charAt(i + 2) == '"') {
133+
state = State.IN_QUOTES;
134+
i = i + 2;
135+
startIndex = i + 1;
136+
weak = true;
137+
continue;
138+
}
139+
}
140+
}
141+
142+
if (logger.isDebugEnabled()) {
143+
logger.debug("Unexpected char at index " + i);
144+
}
145+
}
146+
147+
if (state != State.IN_QUOTES && logger.isDebugEnabled()) {
148+
logger.debug("Expected closing '\"'");
149+
}
150+
151+
return result;
152+
}
153+
154+
155+
private enum State {
156+
157+
BEFORE_QUOTES, IN_QUOTES, AFTER_QUOTES
158+
159+
}
160+
161+
}

spring-web/src/main/java/org/springframework/http/HttpHeaders.java

+14-30
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,6 @@
4040
import java.util.Map;
4141
import java.util.Set;
4242
import java.util.StringJoiner;
43-
import java.util.regex.Matcher;
44-
import java.util.regex.Pattern;
4543
import java.util.stream.Collectors;
4644

4745
import org.springframework.lang.Nullable;
@@ -393,12 +391,6 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
393391
*/
394392
public static final HttpHeaders EMPTY = new ReadOnlyHttpHeaders(new LinkedMultiValueMap<>());
395393

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

404396
private static final ZoneId GMT = ZoneId.of("GMT");
@@ -1568,35 +1560,27 @@ public void clearContentHeaders() {
15681560

15691561
/**
15701562
* Retrieve a combined result from the field values of the ETag header.
1571-
* @param headerName the header name
1563+
* @param name the header name
15721564
* @return the combined result
15731565
* @throws IllegalArgumentException if parsing fails
15741566
* @since 4.3
15751567
*/
1576-
protected List<String> getETagValuesAsList(String headerName) {
1577-
List<String> values = get(headerName);
1578-
if (values != null) {
1579-
List<String> result = new ArrayList<>();
1580-
for (String value : values) {
1581-
if (value != null) {
1582-
Matcher matcher = ETAG_HEADER_VALUE_PATTERN.matcher(value);
1583-
while (matcher.find()) {
1584-
if ("*".equals(matcher.group())) {
1585-
result.add(matcher.group());
1586-
}
1587-
else {
1588-
result.add(matcher.group(1));
1589-
}
1590-
}
1591-
if (result.isEmpty()) {
1592-
throw new IllegalArgumentException(
1593-
"Could not parse header '" + headerName + "' with value '" + value + "'");
1594-
}
1568+
protected List<String> getETagValuesAsList(String name) {
1569+
List<String> values = get(name);
1570+
if (values == null) {
1571+
return Collections.emptyList();
1572+
}
1573+
List<String> result = new ArrayList<>();
1574+
for (String value : values) {
1575+
if (value != null) {
1576+
List<ETag> tags = ETag.parse(value);
1577+
Assert.notEmpty(tags, "Could not parse header '" + name + "' with value '" + value + "'");
1578+
for (ETag tag : tags) {
1579+
result.add(tag.formattedTag());
15951580
}
15961581
}
1597-
return result;
15981582
}
1599-
return Collections.emptyList();
1583+
return result;
16001584
}
16011585

16021586
/**

spring-web/src/main/java/org/springframework/web/context/request/ServletWebRequest.java

+5-13
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -26,13 +26,12 @@
2626
import java.util.Locale;
2727
import java.util.Map;
2828
import java.util.TimeZone;
29-
import java.util.regex.Matcher;
30-
import java.util.regex.Pattern;
3129

3230
import javax.servlet.http.HttpServletRequest;
3331
import javax.servlet.http.HttpServletResponse;
3432
import javax.servlet.http.HttpSession;
3533

34+
import org.springframework.http.ETag;
3635
import org.springframework.http.HttpHeaders;
3736
import org.springframework.http.HttpMethod;
3837
import org.springframework.http.HttpStatus;
@@ -54,12 +53,6 @@ public class ServletWebRequest extends ServletRequestAttributes implements Nativ
5453

5554
private static final List<String> SAFE_METHODS = Arrays.asList("GET", "HEAD");
5655

57-
/**
58-
* Pattern matching ETag multiple field values in headers such as "If-Match", "If-None-Match".
59-
* @see <a href="https://tools.ietf.org/html/rfc7232#section-2.3">Section 2.3 of RFC 7232</a>
60-
*/
61-
private static final Pattern ETAG_HEADER_VALUE_PATTERN = Pattern.compile("\\*|\\s*((W\\/)?(\"[^\"]*\"))\\s*,?");
62-
6356
/**
6457
* Date formats as specified in the HTTP RFC.
6558
* @see <a href="https://tools.ietf.org/html/rfc7231#section-7.1.1.1">Section 7.1.1.1 of RFC 7231</a>
@@ -289,11 +282,10 @@ private boolean validateIfNoneMatch(@Nullable String etag) {
289282
etag = etag.substring(2);
290283
}
291284
while (ifNoneMatch.hasMoreElements()) {
292-
String clientETags = ifNoneMatch.nextElement();
293-
Matcher etagMatcher = ETAG_HEADER_VALUE_PATTERN.matcher(clientETags);
294285
// Compare weak/strong ETags as per https://tools.ietf.org/html/rfc7232#section-2.3
295-
while (etagMatcher.find()) {
296-
if (StringUtils.hasLength(etagMatcher.group()) && etag.equals(etagMatcher.group(3))) {
286+
for (ETag requestedETag : ETag.parse(ifNoneMatch.nextElement())) {
287+
String tag = requestedETag.tag();
288+
if (StringUtils.hasLength(tag) && etag.equals(padEtagIfNecessary(tag))) {
297289
this.notModified = true;
298290
break;
299291
}

0 commit comments

Comments
 (0)