Rewrite CssLinkResourceTransformer for efficient parsing

Prior to this commit, both variants of `CssLinkResourceTransformer`
would use a simple CSS parser for detecting CSS links and delegating to
the transformer for links rewrite.

This commit rewrites the internal parser for a single pass, byte by
byte, memory efficient parsing. This implementation is also more
resistant to edge cases.

Closes gh-37348
This commit is contained in:
Brian Clozel
2026-09-29 19:11:51 +02:00
parent 66910734e2
commit ebef12d92c
8 changed files with 1195 additions and 417 deletions
@@ -0,0 +1,322 @@
/*
* Copyright 2002-present 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.web.reactive.resource;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.core.io.buffer.DataBuffer;
/**
* A lightweight, forward-only parser that recognizes CSS {@code url()}
* and {@code @import} link tokens.
*
* <p>This parser is used by {@link CssLinkResourceTransformer} to rewrite
* CSS links on-the-fly, if needed. Other tokens are handed back as is
* to be written to the output stream. Invalid links (unterminated, containing
* characters not allowed by the CSS syntax, or exceeding {@link #MAX_LINK_LENGTH})
* are skipped and written as-is, and parsing resumes right after them.
*
* @author Brian Clozel
* @since 7.1
*/
final class CssLinkParser {
private static final byte[] URL_KEYWORD = {'u', 'r', 'l', '('};
private static final byte[] IMPORT_KEYWORD = {'@', 'i', 'm', 'p', 'o', 'r', 't'};
private static final int MAX_LINK_LENGTH = 2048;
private State state = State.CONTENT;
private byte[] keyword = URL_KEYWORD;
private int matched;
private byte terminator;
/** Length of an unquoted link, excluding trailing whitespace, or -1 if none seen yet. */
private int linkEnd = -1;
/** Whether the previous byte was an identifier character. */
private boolean previousWasIdentifierChar;
private boolean hasLinks;
private final ByteArrayOutputStream literal = new ByteArrayOutputStream();
private final ByteArrayOutputStream link = new ByteArrayOutputStream();
private List<Token> tokens = new ArrayList<>();
/**
* Feed the next buffer of input to the parser, reading directly from it
* rather than copying it into a {@code byte[]} first. Does not affect the
* buffer's read position, and does not release it; that remains the
* caller's responsibility.
* @return the tokens produced by this buffer
*/
List<Token> feed(DataBuffer buffer) {
buffer.forEachByte(buffer.readPosition(), buffer.readableByteCount(), b -> {
processByte(b);
return true;
});
return flushTokens();
}
/**
* Signal the end of the input.
* @return the remaining tokens
*/
List<Token> end() {
abortLink();
return flushTokens();
}
/**
* Whether at least one link token was produced so far.
*/
boolean hasLinks() {
return this.hasLinks;
}
private void processByte(byte b) {
boolean reexamine;
do {
reexamine = this.state.process(b, this);
}
while (reexamine);
this.previousWasIdentifierChar = State.isIdentifierChar(b);
}
private void appendToLink(byte b) {
this.link.write(b);
// ignore links larger than the maximum length
if (this.link.size() > MAX_LINK_LENGTH) {
abortLink();
}
}
/**
* Emit the first {@code length} bytes of the current link as a link token,
* followed by any remaining bytes and the terminator as literal content.
*/
private void completeLink(int length, byte terminator) {
flushLiteral();
byte[] bytes = this.link.toByteArray();
if (length > 0) {
this.tokens.add(new Token((length < bytes.length ? Arrays.copyOf(bytes, length) : bytes), true));
this.hasLinks = true;
}
this.literal.write(bytes, length, bytes.length - length);
this.literal.write(terminator);
this.link.reset();
this.state = State.CONTENT;
}
/**
* Write the current link back as literal content and resume parsing.
*/
private void abortLink() {
this.literal.write(this.link.toByteArray(), 0, this.link.size());
this.link.reset();
this.state = State.CONTENT;
}
private void flushLiteral() {
if (this.literal.size() > 0) {
this.tokens.add(new Token(this.literal.toByteArray(), false));
this.literal.reset();
}
}
private List<Token> flushTokens() {
flushLiteral();
List<Token> result = this.tokens;
this.tokens = new ArrayList<>();
return result;
}
/**
* Represents the internal state of the {@link CssLinkParser}, which processes
* the input one byte at a time. The flow is shown below:
* <p><pre>
* no match
* +-----------------------+
* v |
* +------> CONTENT --"u" or "@"--> KEYWORD
* ^ ^ |
* | | | "url(" or "@import"
* | | other byte after v
* | +----"@import"---- BEFORE_LINK <--+
* | | | | | whitespace
* | | | +-----+
* | quote | |
* | +------------------------+ +-----------------+
* | | other byte |
* | v after "url(" v
* +<-QUOTED_LINK UNQUOTED_LINK
* ^ closing quote |
* | or invalid link ")" or invalid link |
* +<----------------------------------------------------+
* </pre>
* Links are invalid if they contain an unescaped newline (quoted links),
* a quote, a parenthesis or inner whitespace (unquoted links), or if they
* exceed {@link CssLinkParser#MAX_LINK_LENGTH}. Invalid links are written back
* as literal content, and the byte that invalidated them is re-examined as
* {@link #CONTENT}.
*/
private enum State {
CONTENT {
@Override
boolean process(byte b, CssLinkParser parser) {
parser.literal.write(b);
// "url(" keyword, unless part of a longer identifier
if (b == URL_KEYWORD[0] && !parser.previousWasIdentifierChar) {
beginKeyword(parser, URL_KEYWORD);
}
// "@import" keyword
else if (b == IMPORT_KEYWORD[0]) {
beginKeyword(parser, IMPORT_KEYWORD);
}
return false;
}
private void beginKeyword(CssLinkParser parser, byte[] keyword) {
parser.keyword = keyword;
parser.matched = 1;
parser.state = State.KEYWORD;
}
},
KEYWORD {
@Override
boolean process(byte b, CssLinkParser parser) {
// current byte matching the keyword
if (parser.matched < parser.keyword.length && b == parser.keyword[parser.matched]) {
parser.literal.write(b);
parser.matched++;
if (parser.keyword == URL_KEYWORD && parser.matched == URL_KEYWORD.length) {
parser.state = State.BEFORE_LINK;
}
return false;
}
// "@import" must not be part of a longer identifier;
// other bytes are re-examined as content.
boolean complete = (parser.matched == parser.keyword.length && !isIdentifierChar(b));
parser.state = (complete ? State.BEFORE_LINK : State.CONTENT);
return true;
}
},
BEFORE_LINK {
@Override
boolean process(byte b, CssLinkParser parser) {
if (isCssWhitespace(b)) {
parser.literal.write(b);
return false;
}
if (b == '\'' || b == '"') {
parser.literal.write(b);
parser.terminator = b;
parser.state = State.QUOTED_LINK;
return false;
}
if (parser.keyword == URL_KEYWORD) {
parser.linkEnd = -1;
parser.state = State.UNQUOTED_LINK;
return true;
}
// @import with url(...) following.
parser.state = State.CONTENT;
return true;
}
},
QUOTED_LINK {
@Override
boolean process(byte b, CssLinkParser parser) {
if (b == parser.terminator) {
parser.completeLink(parser.link.size(), b);
return false;
}
// unescaped newlines are not allowed in CSS strings
if (b == '\n' || b == '\r' || b == '\f') {
parser.abortLink();
return true;
}
parser.appendToLink(b);
return false;
}
},
UNQUOTED_LINK {
@Override
boolean process(byte b, CssLinkParser parser) {
if (b == ')') {
parser.completeLink((parser.linkEnd != -1 ? parser.linkEnd : parser.link.size()), b);
return false;
}
if (isCssWhitespace(b)) {
if (parser.linkEnd == -1) {
parser.linkEnd = parser.link.size();
}
parser.appendToLink(b);
return false;
}
// only whitespace is allowed before the closing parenthesis,
// and quotes and parentheses are not allowed in unquoted urls
if (parser.linkEnd != -1 || b == '\'' || b == '"' || b == '(') {
parser.abortLink();
return true;
}
parser.appendToLink(b);
return false;
}
};
abstract boolean process(byte b, CssLinkParser parser);
private static boolean isIdentifierChar(byte b) {
return ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') ||
b == '-' || b == '_' || b < 0);
}
private static boolean isCssWhitespace(byte b) {
return (b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\f');
}
}
/**
* A span of bytes emitted by the parser: either literal content to write
* through unchanged, or the contents of a link (without its surrounding
* keyword, whitespace, quotes or parenthesis).
* @param bytes the span of bytes
* @param link whether {@code bytes} is a link, as opposed to literal content
*/
record Token(byte[] bytes, boolean link) {
}
}
@@ -16,19 +16,11 @@
package org.springframework.web.reactive.resource;
import java.io.StringWriter;
import java.io.ByteArrayOutputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jspecify.annotations.Nullable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -52,22 +44,13 @@ import org.springframework.web.server.ServerWebExchange;
* the original link is preserved.
*
* @author Rossen Stoyanchev
* @author Brian Clozel
* @since 5.0
*/
public class CssLinkResourceTransformer extends ResourceTransformerSupport {
private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
private static final Log logger = LogFactory.getLog(CssLinkResourceTransformer.class);
private final List<LinkParser> linkParsers = new ArrayList<>(2);
public CssLinkResourceTransformer() {
this.linkParsers.add(new ImportLinkParser());
this.linkParsers.add(new UrlFunctionLinkParser());
}
@Override
@SuppressWarnings("deprecation")
@@ -83,63 +66,52 @@ public class CssLinkResourceTransformer extends ResourceTransformerSupport {
}
DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory();
Flux<DataBuffer> flux = DataBufferUtils
.read(outputResource, bufferFactory, StreamUtils.BUFFER_SIZE);
return DataBufferUtils.join(flux)
.flatMap(dataBuffer -> {
String cssContent = dataBuffer.toString(DEFAULT_CHARSET);
DataBufferUtils.release(dataBuffer);
return transformContent(cssContent, outputResource, transformerChain, exchange);
});
return transformContent(outputResource, bufferFactory, transformerChain, exchange);
});
}
private Mono<? extends Resource> transformContent(String cssContent, Resource resource,
private Mono<? extends Resource> transformContent(Resource resource, DataBufferFactory bufferFactory,
ResourceTransformerChain chain, ServerWebExchange exchange) {
List<ContentChunkInfo> contentChunkInfos = parseContent(cssContent);
if (contentChunkInfos.isEmpty()) {
return Mono.just(resource);
}
return Flux.fromIterable(contentChunkInfos)
.concatMap(contentChunkInfo -> {
String contentChunk = contentChunkInfo.getContent(cssContent);
if (contentChunkInfo.isLink() && !hasScheme(contentChunk)) {
String link = toAbsolutePath(contentChunk, exchange);
return resolveUrlPath(link, exchange, resource, chain).defaultIfEmpty(contentChunk);
}
else {
return Mono.just(contentChunk);
}
})
.reduce(new StringWriter(), (writer, chunk) -> {
writer.write(chunk);
return writer;
})
.map(writer -> {
byte[] newContent = writer.toString().getBytes(DEFAULT_CHARSET);
return new TransformedResource(resource, newContent);
});
// Parsing state is created per subscription
return Mono.defer(() -> {
CssLinkParser parser = new CssLinkParser();
Flux<DataBuffer> flux = DataBufferUtils.read(resource, bufferFactory, StreamUtils.BUFFER_SIZE);
return flux
.concatMap(buffer -> Flux.fromIterable(feedAndRelease(parser, buffer)))
.concatWith(Flux.defer(() -> Flux.fromIterable(parser.end())))
.concatMap(token -> resolveToken(token, resource, chain, exchange))
.reduceWith(ByteArrayOutputStream::new, (out, bytes) -> {
out.write(bytes, 0, bytes.length);
return out;
})
.map(out -> parser.hasLinks() ? new TransformedResource(resource, out.toByteArray()) : resource);
});
}
private List<ContentChunkInfo> parseContent(String cssContent) {
SortedSet<ContentChunkInfo> links = new TreeSet<>();
this.linkParsers.forEach(parser -> parser.parse(cssContent, links));
if (links.isEmpty()) {
return Collections.emptyList();
private List<CssLinkParser.Token> feedAndRelease(CssLinkParser parser, DataBuffer buffer) {
try {
return parser.feed(buffer);
}
int index = 0;
List<ContentChunkInfo> result = new ArrayList<>();
for (ContentChunkInfo link : links) {
result.add(new ContentChunkInfo(index, link.getStart(), false));
result.add(link);
index = link.getEnd();
finally {
DataBufferUtils.release(buffer);
}
if (index < cssContent.length()) {
result.add(new ContentChunkInfo(index, cssContent.length(), false));
}
private Mono<byte[]> resolveToken(CssLinkParser.Token token, Resource resource,
ResourceTransformerChain chain, ServerWebExchange exchange) {
if (!token.link()) {
return Mono.just(token.bytes());
}
return result;
String link = new String(token.bytes(), DEFAULT_CHARSET);
if (hasScheme(link)) {
return Mono.just(token.bytes());
}
String absolutePath = toAbsolutePath(link, exchange);
return resolveUrlPath(absolutePath, exchange, resource, chain)
.defaultIfEmpty(link)
.map(resolved -> resolved.getBytes(DEFAULT_CHARSET));
}
private boolean hasScheme(String link) {
@@ -147,158 +119,4 @@ public class CssLinkResourceTransformer extends ResourceTransformerSupport {
return (schemeIndex > 0 && !link.substring(0, schemeIndex).contains("/")) || link.indexOf("//") == 0;
}
/**
* Extract content chunks that represent links.
*/
@FunctionalInterface
protected interface LinkParser {
void parse(String cssContent, SortedSet<ContentChunkInfo> result);
}
/**
* Abstract base class for {@link LinkParser} implementations.
*/
protected abstract static class AbstractLinkParser implements LinkParser {
/** Return the keyword to use to search for links, for example, "@import", "url(". */
protected abstract String getKeyword();
@Override
public void parse(String content, SortedSet<ContentChunkInfo> result) {
int position = 0;
while (true) {
position = content.indexOf(getKeyword(), position);
if (position == -1) {
return;
}
position += getKeyword().length();
while (position < content.length() && Character.isWhitespace(content.charAt(position))) {
position++;
}
if (position == content.length()) {
return;
}
if (content.charAt(position) == '\'') {
position = extractLink(position, '\'', content, result);
}
else if (content.charAt(position) == '"') {
position = extractLink(position, '"', content, result);
}
else {
position = extractUnquotedLink(position, content, result);
}
}
}
protected int extractLink(int index, char endChar, String content, Set<ContentChunkInfo> result) {
int start = index + 1;
int end = content.indexOf(endChar, start);
if (end == -1) {
if (logger.isTraceEnabled()) {
logger.trace("Unterminated link at index " + start + ", no closing '" + endChar + "'");
}
return content.length();
}
result.add(new ContentChunkInfo(start, end, true));
return end + 1;
}
/**
* Invoked after a keyword match, after whitespace has been removed, and when
* the next char is neither a single nor double quote.
*/
protected abstract int extractUnquotedLink(int position, String content,
Set<ContentChunkInfo> linksToAdd);
}
private static class ImportLinkParser extends AbstractLinkParser {
@Override
protected String getKeyword() {
return "@import";
}
@Override
protected int extractUnquotedLink(int position, String content, Set<ContentChunkInfo> result) {
if (content.startsWith("url(", position)) {
// Ignore: UrlFunctionLinkParser will handle it.
}
else if (logger.isTraceEnabled()) {
logger.trace("Unexpected syntax for @import link at index " + position);
}
return position;
}
}
private static class UrlFunctionLinkParser extends AbstractLinkParser {
@Override
protected String getKeyword() {
return "url(";
}
@Override
protected int extractUnquotedLink(int position, String content, Set<ContentChunkInfo> result) {
// A url() function without unquoted
return extractLink(position - 1, ')', content, result);
}
}
private static class ContentChunkInfo implements Comparable<ContentChunkInfo> {
private final int start;
private final int end;
private final boolean isLink;
ContentChunkInfo(int start, int end, boolean isLink) {
this.start = start;
this.end = end;
this.isLink = isLink;
}
public int getStart() {
return this.start;
}
public int getEnd() {
return this.end;
}
public boolean isLink() {
return this.isLink;
}
public String getContent(String fullContent) {
return fullContent.substring(this.start, this.end);
}
@Override
public int compareTo(ContentChunkInfo other) {
return Integer.compare(this.start, other.start);
}
@Override
public boolean equals(@Nullable Object other) {
return (this == other || (other instanceof ContentChunkInfo that &&
this.start == that.start && this.end == that.end));
}
@Override
public int hashCode() {
return this.start * 31 + this.end;
}
}
}
@@ -0,0 +1,242 @@
/*
* Copyright 2002-present 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.web.reactive.resource;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link CssLinkParser}.
* @author Brian Clozel
*/
class CssLinkParserTests {
private static final int[] CHUNK_SIZES = {1, 2, 3, 7, 13, 4096};
private static final DataBufferFactory bufferFactory = new DefaultDataBufferFactory();
@Test
void quotedUrlFunctionWithDoubleQuotes() {
assertLinks("body { background: url(\"foo.png\") }", "foo.png");
}
@Test
void quotedUrlFunctionWithSingleQuotes() {
assertLinks("body { background: url('foo.png') }", "foo.png");
}
@Test
void unquotedUrlFunction() {
assertLinks("body { background: url(foo.png) }", "foo.png");
}
@Test
void importWithDoubleQuotedString() {
assertLinks("@import \"foo.css\";", "foo.css");
}
@Test
void importWithSingleQuotedString() {
assertLinks("@import 'foo.css';", "foo.css");
}
@Test
void importWithQuotedUrlFunction() {
assertLinks("@import url(\"foo.css\");", "foo.css");
}
@Test
void importWithUnquotedUrlFunction() {
assertLinks("@import url(foo.css);", "foo.css");
}
@Test
void whitespaceInsideParenthesesAroundQuotedLink() {
assertLinks("body { background: url( \"foo.png\" ) }", "foo.png");
}
@Test
void whitespaceIncludingNewlinesAndTabsBeforeQuotedLink() {
assertLinks("body { background: url(\n\t'foo.png'\n) }", "foo.png");
}
@Test
void tabAfterImportKeyword() {
assertLinks("@import\t\"foo.css\";", "foo.css");
}
@Test
void invalidKeywordIsSkipped() {
assertLinks("body { background: uurl(foo.png) }");
}
@Test
void urlFunctionAfterNonIdentifierCharacterIsStillAKeyword() {
assertLinks("body { background: (url(foo.png)) }", "foo.png");
}
@Test
void importWithoutQuoteOrUrlFunctionProducesNoLink() {
assertLinks("@import foo.css;");
}
@Test // https://github.com/spring-projects/spring-framework/issues/22602
void emptyUrlFunctionProducesNoLink() {
assertLinks(".fooStyle { background: transparent url() no-repeat left top; }");
}
@Test
void emptyUrlFunctionWithWhitespaceProducesNoLink() {
assertLinks("body { background: url( ) }");
}
@Test
void nonAsciiContentAroundLinkPassesThroughUntouched() {
assertLinks("café { background: url(\"héllo.png\") } 世界", "héllo.png");
}
@Test
void nonAsciiContentWithoutAnyLink() {
assertLinks("café { color: red; } 世界");
}
@Test
void unterminatedUnquotedLinkIsSkipped() {
assertLinks("body { background: url(images/missing.png");
}
@Test
void unterminatedDoubleQuotedLinkIsSkipped() {
assertLinks("body { background: url(\"images/missing.png");
}
@Test
void unterminatedSingleQuotedLinkSpanningIntoNextIsSkipped() {
assertLinks("a{background:url('x.png}\nb{color:red}");
}
@Test
void endOfFileImmediatelyAfterUrlFunctionKeywordDoesNotThrow() {
assertLinks("body { background: url(");
}
@Test
void trailingWhitespaceAfterImportAtEndOfFileDoesNotThrow() {
assertLinks("@import ");
}
@Test
void runawayUnterminatedLinkIsBoundedAndParsingRecovers() {
String longUnterminated = "a".repeat(4096);
assertLinks("body { background: url('" + longUnterminated + " div { background: url('second.png') }",
"second.png");
}
@Test
void unterminatedQuotedLinkStopsAtNewlineAndNextLinkIsFound() {
assertLinks("a{background:url('x.png}\nb{background:url('b.png')}", "b.png");
}
@Test
void unterminatedUnquotedLinkStopsAtNewlineAndNextLinkIsFound() {
assertLinks("a{background:url(x.png\nb{background:url(b.png)}", "b.png");
}
@Test
void unquotedLinkWithTrailingWhitespaceExcludesWhitespace() {
assertLinks("body { background: url( foo.png \t) }", "foo.png");
}
@Test
void unquotedLinkWithInnerWhitespaceIsInvalid() {
assertLinks("a{background:url(foo bar.png)} b{background:url(b.png)}", "b.png");
}
@Test
void unquotedLinkWithQuoteIsInvalid() {
assertLinks("a{background:url(foo'bar.png)} b{background:url(b.png)}", "b.png");
}
@Test
void unquotedLinkWithParenthesisIsInvalid() {
assertLinks("a{background:url(foo(bar.png)} b{background:url(b.png)}", "b.png");
}
@Test
void unterminatedUnquotedLinkWithTrailingWhitespaceAtEndOfFile() {
assertLinks("body { background: url(foo.png ");
}
@Test
void unterminatedLinksIsSkipped() {
String pathological = "url(x".repeat(200_000);
assertLinks(pathological);
}
private static void assertLinks(String cssContent, String... expectedLinks) {
byte[] input = cssContent.getBytes(UTF_8);
for (int chunkSize : CHUNK_SIZES) {
List<String> links = new ArrayList<>();
byte[] output = parse(input, chunkSize, links);
assertThat(output)
.describedAs("reconstructed output at chunk size " + chunkSize)
.isEqualTo(input);
assertThat(links)
.describedAs("extracted links at chunk size " + chunkSize)
.containsExactly(expectedLinks);
}
}
private static byte[] parse(byte[] input, int chunkSize, List<String> links) {
CssLinkParser parser = new CssLinkParser();
List<CssLinkParser.Token> tokens = new ArrayList<>();
for (int i = 0; i < input.length; i += chunkSize) {
int len = Math.min(chunkSize, input.length - i);
DataBuffer buffer = bufferFactory.wrap(Arrays.copyOfRange(input, i, i + len));
try {
tokens.addAll(parser.feed(buffer));
}
finally {
DataBufferUtils.release(buffer);
}
}
tokens.addAll(parser.end());
ByteArrayOutputStream output = new ByteArrayOutputStream();
for (CssLinkParser.Token token : tokens) {
output.write(token.bytes(), 0, token.bytes().length);
if (token.link()) {
links.add(new String(token.bytes(), UTF_8));
}
}
return output.toByteArray();
}
}
@@ -167,18 +167,12 @@ class CssLinkResourceTransformerTests {
@Test // https://github.com/spring-projects/spring-framework/issues/22602
void transformEmptyUrlFunction() {
MockServerWebExchange exchange = MockServerWebExchange.from(get("/static/empty_url_function.css"));
Resource css = getResource("empty_url_function.css");
String expected = """
.fooStyle {
background: transparent url() no-repeat left top;
}""";
Resource expected = getResource("empty_url_function.css");
StepVerifier.create(this.transformerChain.transform(exchange, css)
.cast(TransformedResource.class))
.consumeNextWith(transformedResource -> {
String result = new String(transformedResource.getByteArray(), UTF_8);
assertThat(result).isEqualToNormalizingNewlines(expected);
})
// An empty url() is not a link at all, so the resource is left untouched,
// exactly like a CSS file that has no link in it at all.
StepVerifier.create(this.transformerChain.transform(exchange, expected))
.consumeNextWith(resource -> assertThat(resource).isSameAs(expected))
.expectComplete()
.verify();
}
@@ -0,0 +1,316 @@
/*
* Copyright 2002-present 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.web.servlet.resource;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* A lightweight, forward-only parser that recognizes CSS {@code url()}
* and {@code @import} link tokens.
*
* <p>This parser is used by {@link CssLinkResourceTransformer} to rewrite
* CSS links on-the-fly, if needed. Other tokens are handed back as is
* to be written to the output stream. Invalid links (unterminated, containing
* characters not allowed by the CSS syntax, or exceeding {@link #MAX_LINK_LENGTH})
* are skipped and written as-is, and parsing resumes right after them.
*
* @author Brian Clozel
* @since 7.1
*/
final class CssLinkParser {
private static final byte[] URL_KEYWORD = {'u', 'r', 'l', '('};
private static final byte[] IMPORT_KEYWORD = {'@', 'i', 'm', 'p', 'o', 'r', 't'};
private static final int MAX_LINK_LENGTH = 2048;
private State state = State.CONTENT;
private byte[] keyword = URL_KEYWORD;
private int matched;
private byte terminator;
/** Length of an unquoted link, excluding trailing whitespace, or -1 if none seen yet. */
private int linkEnd = -1;
/** Whether the previous byte was an identifier character. */
private boolean previousWasIdentifierChar;
private boolean hasLinks;
private final ByteArrayOutputStream literal = new ByteArrayOutputStream();
private final ByteArrayOutputStream link = new ByteArrayOutputStream();
private List<Token> tokens = new ArrayList<>();
/**
* Feed the next chunk of input to the parser.
* @return the tokens produced by this chunk
*/
List<Token> feed(byte[] bytes, int offset, int length) {
for (int i = offset; i < offset + length; i++) {
processByte(bytes[i]);
}
return flushTokens();
}
/**
* Signal the end of the input.
* @return the remaining tokens
*/
List<Token> end() {
abortLink();
return flushTokens();
}
/**
* Whether at least one link token was produced so far.
*/
boolean hasLinks() {
return this.hasLinks;
}
private void processByte(byte b) {
boolean reexamine;
do {
reexamine = this.state.process(b, this);
}
while (reexamine);
this.previousWasIdentifierChar = State.isIdentifierChar(b);
}
private void appendToLink(byte b) {
this.link.write(b);
// ignore links larger than the maximum length
if (this.link.size() > MAX_LINK_LENGTH) {
abortLink();
}
}
/**
* Emit the first {@code length} bytes of the current link as a link token,
* followed by any remaining bytes and the terminator as literal content.
*/
private void completeLink(int length, byte terminator) {
flushLiteral();
byte[] bytes = this.link.toByteArray();
if (length > 0) {
this.tokens.add(new Token((length < bytes.length ? Arrays.copyOf(bytes, length) : bytes), true));
this.hasLinks = true;
}
this.literal.write(bytes, length, bytes.length - length);
this.literal.write(terminator);
this.link.reset();
this.state = State.CONTENT;
}
/**
* Write the current link back as literal content and resume parsing.
*/
private void abortLink() {
this.literal.write(this.link.toByteArray(), 0, this.link.size());
this.link.reset();
this.state = State.CONTENT;
}
private void flushLiteral() {
if (this.literal.size() > 0) {
this.tokens.add(new Token(this.literal.toByteArray(), false));
this.literal.reset();
}
}
private List<Token> flushTokens() {
flushLiteral();
List<Token> result = this.tokens;
this.tokens = new ArrayList<>();
return result;
}
/**
* Represents the internal state of the {@link CssLinkParser}, which processes
* the input one byte at a time. The flow is shown below:
* <p><pre>
* no match
* +-----------------------+
* v |
* +------> CONTENT --"u" or "@"--> KEYWORD
* ^ ^ |
* | | | "url(" or "@import"
* | | other byte after v
* | +----"@import"---- BEFORE_LINK <--+
* | | | | | whitespace
* | | | +-----+
* | quote | |
* | +------------------------+ +-----------------+
* | | other byte |
* | v after "url(" v
* +<-QUOTED_LINK UNQUOTED_LINK
* ^ closing quote |
* | or invalid link ")" or invalid link |
* +<----------------------------------------------------+
* </pre>
* Links are invalid if they contain an unescaped newline (quoted links),
* a quote, a parenthesis or inner whitespace (unquoted links), or if they
* exceed {@link CssLinkParser#MAX_LINK_LENGTH}. Invalid links are written back
* as literal content, and the byte that invalidated them is re-examined as
* {@link #CONTENT}.
*/
private enum State {
CONTENT {
@Override
boolean process(byte b, CssLinkParser parser) {
parser.literal.write(b);
// "url(" keyword, unless part of a longer identifier
if (b == URL_KEYWORD[0] && !parser.previousWasIdentifierChar) {
beginKeyword(parser, URL_KEYWORD);
}
// "@import" keyword
else if (b == IMPORT_KEYWORD[0]) {
beginKeyword(parser, IMPORT_KEYWORD);
}
return false;
}
private void beginKeyword(CssLinkParser parser, byte[] keyword) {
parser.keyword = keyword;
parser.matched = 1;
parser.state = State.KEYWORD;
}
},
KEYWORD {
@Override
boolean process(byte b, CssLinkParser parser) {
// current byte matching the keyword
if (parser.matched < parser.keyword.length && b == parser.keyword[parser.matched]) {
parser.literal.write(b);
parser.matched++;
if (parser.keyword == URL_KEYWORD && parser.matched == URL_KEYWORD.length) {
parser.state = State.BEFORE_LINK;
}
return false;
}
// "@import" must not be part of a longer identifier;
// other bytes are re-examined as content.
boolean complete = (parser.matched == parser.keyword.length && !isIdentifierChar(b));
parser.state = (complete ? State.BEFORE_LINK : State.CONTENT);
return true;
}
},
BEFORE_LINK {
@Override
boolean process(byte b, CssLinkParser parser) {
if (isCssWhitespace(b)) {
parser.literal.write(b);
return false;
}
if (b == '\'' || b == '"') {
parser.literal.write(b);
parser.terminator = b;
parser.state = State.QUOTED_LINK;
return false;
}
if (parser.keyword == URL_KEYWORD) {
parser.linkEnd = -1;
parser.state = State.UNQUOTED_LINK;
return true;
}
// @import with url(...) following.
parser.state = State.CONTENT;
return true;
}
},
QUOTED_LINK {
@Override
boolean process(byte b, CssLinkParser parser) {
if (b == parser.terminator) {
parser.completeLink(parser.link.size(), b);
return false;
}
// unescaped newlines are not allowed in CSS strings
if (b == '\n' || b == '\r' || b == '\f') {
parser.abortLink();
return true;
}
parser.appendToLink(b);
return false;
}
},
UNQUOTED_LINK {
@Override
boolean process(byte b, CssLinkParser parser) {
if (b == ')') {
parser.completeLink((parser.linkEnd != -1 ? parser.linkEnd : parser.link.size()), b);
return false;
}
if (isCssWhitespace(b)) {
if (parser.linkEnd == -1) {
parser.linkEnd = parser.link.size();
}
parser.appendToLink(b);
return false;
}
// only whitespace is allowed before the closing parenthesis,
// and quotes and parentheses are not allowed in unquoted urls
if (parser.linkEnd != -1 || b == '\'' || b == '"' || b == '(') {
parser.abortLink();
return true;
}
parser.appendToLink(b);
return false;
}
};
abstract boolean process(byte b, CssLinkParser parser);
private static boolean isIdentifierChar(byte b) {
return ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') ||
b == '-' || b == '_' || b < 0);
}
private static boolean isCssWhitespace(byte b) {
return (b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\f');
}
}
/**
* A span of bytes emitted by the parser: either literal content to write
* through unchanged, or the contents of a link (without its surrounding
* keyword, whitespace, quotes or parenthesis).
* @param bytes the span of bytes
* @param link whether {@code bytes} is a link, as opposed to literal content
*/
record Token(byte[] bytes, boolean link) {
}
}
@@ -16,22 +16,17 @@
package org.springframework.web.servlet.resource;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringWriter;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jspecify.annotations.Nullable;
import org.springframework.core.io.Resource;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
/**
@@ -46,22 +41,13 @@ import org.springframework.util.StringUtils;
* the original link is preserved.
*
* @author Rossen Stoyanchev
* @author Brian Clozel
* @since 4.1
*/
public class CssLinkResourceTransformer extends ResourceTransformerSupport {
private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
private static final Log logger = LogFactory.getLog(CssLinkResourceTransformer.class);
private final List<LinkParser> linkParsers = new ArrayList<>(2);
public CssLinkResourceTransformer() {
this.linkParsers.add(new ImportStatementLinkParser());
this.linkParsers.add(new UrlFunctionLinkParser());
}
@SuppressWarnings("deprecation")
@Override
@@ -76,34 +62,42 @@ public class CssLinkResourceTransformer extends ResourceTransformerSupport {
return resource;
}
byte[] bytes = FileCopyUtils.copyToByteArray(resource.getInputStream());
String content = new String(bytes, DEFAULT_CHARSET);
SortedSet<ContentChunkInfo> links = new TreeSet<>();
for (LinkParser parser : this.linkParsers) {
parser.parse(content, links);
CssLinkParser parser = new CssLinkParser();
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[StreamUtils.BUFFER_SIZE];
try (InputStream in = resource.getInputStream()) {
int read;
while ((read = in.read(buffer)) != -1) {
writeTokens(parser.feed(buffer, 0, read), request, resource, transformerChain, output);
}
}
writeTokens(parser.end(), request, resource, transformerChain, output);
if (links.isEmpty()) {
if (!parser.hasLinks()) {
return resource;
}
return new TransformedResource(resource, output.toByteArray());
}
int index = 0;
StringWriter writer = new StringWriter();
for (ContentChunkInfo linkContentChunkInfo : links) {
writer.write(content.substring(index, linkContentChunkInfo.getStart()));
String link = content.substring(linkContentChunkInfo.getStart(), linkContentChunkInfo.getEnd());
String newLink = null;
if (!hasScheme(link)) {
String absolutePath = toAbsolutePath(link, request);
newLink = resolveUrlPath(absolutePath, request, resource, transformerChain);
}
writer.write(newLink != null ? newLink : link);
index = linkContentChunkInfo.getEnd();
private void writeTokens(List<CssLinkParser.Token> tokens, HttpServletRequest request, Resource resource,
ResourceTransformerChain transformerChain, ByteArrayOutputStream output) {
for (CssLinkParser.Token token : tokens) {
byte[] bytes = (token.link() ? resolveLink(token.bytes(), request, resource, transformerChain) : token.bytes());
output.write(bytes, 0, bytes.length);
}
writer.write(content.substring(index));
}
return new TransformedResource(resource, writer.toString().getBytes(DEFAULT_CHARSET));
private byte[] resolveLink(byte[] linkBytes, HttpServletRequest request, Resource resource,
ResourceTransformerChain transformerChain) {
String link = new String(linkBytes, DEFAULT_CHARSET);
String newLink = null;
if (!hasScheme(link)) {
String absolutePath = toAbsolutePath(link, request);
newLink = resolveUrlPath(absolutePath, request, resource, transformerChain);
}
return (newLink != null ? newLink.getBytes(DEFAULT_CHARSET) : linkBytes);
}
private boolean hasScheme(String link) {
@@ -111,143 +105,4 @@ public class CssLinkResourceTransformer extends ResourceTransformerSupport {
return ((schemeIndex > 0 && !link.substring(0, schemeIndex).contains("/")) || link.indexOf("//") == 0);
}
/**
* Extract content chunks that represent links.
*/
@FunctionalInterface
protected interface LinkParser {
void parse(String content, SortedSet<ContentChunkInfo> result);
}
/**
* Abstract base class for {@link LinkParser} implementations.
*/
protected abstract static class AbstractLinkParser implements LinkParser {
/** Return the keyword to use to search for links, for example, "@import", "url(". */
protected abstract String getKeyword();
@Override
public void parse(String content, SortedSet<ContentChunkInfo> result) {
int position = 0;
while (true) {
position = content.indexOf(getKeyword(), position);
if (position == -1) {
return;
}
position += getKeyword().length();
while (position < content.length() && Character.isWhitespace(content.charAt(position))) {
position++;
}
if (position == content.length()) {
return;
}
if (content.charAt(position) == '\'') {
position = extractLink(position, "'", content, result);
}
else if (content.charAt(position) == '"') {
position = extractLink(position, "\"", content, result);
}
else {
position = extractLink(position, content, result);
}
}
}
protected int extractLink(int index, String endKey, String content, SortedSet<ContentChunkInfo> linksToAdd) {
int start = index + 1;
int end = content.indexOf(endKey, start);
if (end == -1) {
if (logger.isTraceEnabled()) {
logger.trace("Unterminated link at index " + start + ", no closing \"" + endKey + "\"");
}
return content.length();
}
linksToAdd.add(new ContentChunkInfo(start, end));
return end + endKey.length();
}
/**
* Invoked after a keyword match, after whitespace has been removed, and when
* the next char is neither a single nor double quote.
*/
protected abstract int extractLink(int index, String content, SortedSet<ContentChunkInfo> linksToAdd);
}
private static class ImportStatementLinkParser extends AbstractLinkParser {
@Override
protected String getKeyword() {
return "@import";
}
@Override
protected int extractLink(int index, String content, SortedSet<ContentChunkInfo> linksToAdd) {
if (content.startsWith("url(", index)) {
// Ignore: UrlFunctionLinkParser will handle it.
}
else if (logger.isTraceEnabled()) {
logger.trace("Unexpected syntax for @import link at index " + index);
}
return index;
}
}
private static class UrlFunctionLinkParser extends AbstractLinkParser {
@Override
protected String getKeyword() {
return "url(";
}
@Override
protected int extractLink(int index, String content, SortedSet<ContentChunkInfo> linksToAdd) {
// A url() function without unquoted
return extractLink(index - 1, ")", content, linksToAdd);
}
}
private static class ContentChunkInfo implements Comparable<ContentChunkInfo> {
private final int start;
private final int end;
ContentChunkInfo(int start, int end) {
this.start = start;
this.end = end;
}
public int getStart() {
return this.start;
}
public int getEnd() {
return this.end;
}
@Override
public int compareTo(ContentChunkInfo other) {
return Integer.compare(this.start, other.start);
}
@Override
public boolean equals(@Nullable Object other) {
return (this == other || (other instanceof ContentChunkInfo that &&
this.start == that.start && this.end == that.end));
}
@Override
public int hashCode() {
return this.start * 31 + this.end;
}
}
}
@@ -0,0 +1,234 @@
/*
* Copyright 2002-present 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.web.servlet.resource;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link CssLinkParser}.
*
* <p>Every case is run at chunk sizes 1, 2, 3, 7, 13 and 4096 bytes, asserting
* identical output at every chunk size: that is what proves the parser is safe
* across arbitrary buffer boundaries (relevant to the webflux {@code DataBuffer}
* based caller in particular).
*
* @author Brian Clozel
*/
class CssLinkParserTests {
private static final int[] CHUNK_SIZES = {1, 2, 3, 7, 13, 4096};
@Test
void quotedUrlFunctionWithDoubleQuotes() {
assertLinks("body { background: url(\"foo.png\") }", "foo.png");
}
@Test
void quotedUrlFunctionWithSingleQuotes() {
assertLinks("body { background: url('foo.png') }", "foo.png");
}
@Test
void unquotedUrlFunction() {
assertLinks("body { background: url(foo.png) }", "foo.png");
}
@Test
void importWithDoubleQuotedString() {
assertLinks("@import \"foo.css\";", "foo.css");
}
@Test
void importWithSingleQuotedString() {
assertLinks("@import 'foo.css';", "foo.css");
}
@Test
void importWithQuotedUrlFunction() {
assertLinks("@import url(\"foo.css\");", "foo.css");
}
@Test
void importWithUnquotedUrlFunction() {
assertLinks("@import url(foo.css);", "foo.css");
}
@Test
void whitespaceInsideParenthesesAroundQuotedLink() {
assertLinks("body { background: url( \"foo.png\" ) }", "foo.png");
}
@Test
void whitespaceIncludingNewlinesAndTabsBeforeQuotedLink() {
assertLinks("body { background: url(\n\t'foo.png'\n) }", "foo.png");
}
@Test
void tabAfterImportKeyword() {
assertLinks("@import\t\"foo.css\";", "foo.css");
}
@Test
void invalidKeywordIsSkipped() {
assertLinks("body { background: uurl(foo.png) }");
}
@Test
void urlFunctionAfterNonIdentifierCharacterIsStillAKeyword() {
assertLinks("body { background: (url(foo.png)) }", "foo.png");
}
@Test
void importWithoutQuoteOrUrlFunctionProducesNoLink() {
assertLinks("@import foo.css;");
}
@Test // https://github.com/spring-projects/spring-framework/issues/22602
void emptyUrlFunctionProducesNoLink() {
assertLinks(".fooStyle { background: transparent url() no-repeat left top; }");
}
@Test
void emptyUrlFunctionWithWhitespaceProducesNoLink() {
assertLinks("body { background: url( ) }");
}
@Test
void nonAsciiContentAroundLinkPassesThroughUntouched() {
assertLinks("café { background: url(\"héllo.png\") } 世界", "héllo.png");
}
@Test
void nonAsciiContentWithoutAnyLink() {
assertLinks("café { color: red; } 世界");
}
@Test
void unterminatedUnquotedLinkIsSkipped() {
assertLinks("body { background: url(images/missing.png");
}
@Test
void unterminatedDoubleQuotedLinkIsSkipped() {
assertLinks("body { background: url(\"images/missing.png");
}
@Test
void unterminatedSingleQuotedLinkSpanningIntoNextIsSkipped() {
assertLinks("a{background:url('x.png}\nb{color:red}");
}
@Test
void endOfFileImmediatelyAfterUrlFunctionKeywordDoesNotThrow() {
assertLinks("body { background: url(");
}
@Test
void trailingWhitespaceAfterImportAtEndOfFileDoesNotThrow() {
assertLinks("@import ");
}
@Test
void runawayUnterminatedLinkIsBoundedAndParsingRecovers() {
String longUnterminated = "a".repeat(4096);
assertLinks("body { background: url('" + longUnterminated + " div { background: url('second.png') }",
"second.png");
}
@Test
void unterminatedQuotedLinkStopsAtNewlineAndNextLinkIsFound() {
assertLinks("a{background:url('x.png}\nb{background:url('b.png')}", "b.png");
}
@Test
void unterminatedUnquotedLinkStopsAtNewlineAndNextLinkIsFound() {
assertLinks("a{background:url(x.png\nb{background:url(b.png)}", "b.png");
}
@Test
void unquotedLinkWithTrailingWhitespaceExcludesWhitespace() {
assertLinks("body { background: url( foo.png \t) }", "foo.png");
}
@Test
void unquotedLinkWithInnerWhitespaceIsInvalid() {
assertLinks("a{background:url(foo bar.png)} b{background:url(b.png)}", "b.png");
}
@Test
void unquotedLinkWithQuoteIsInvalid() {
assertLinks("a{background:url(foo'bar.png)} b{background:url(b.png)}", "b.png");
}
@Test
void unquotedLinkWithParenthesisIsInvalid() {
assertLinks("a{background:url(foo(bar.png)} b{background:url(b.png)}", "b.png");
}
@Test
void unterminatedUnquotedLinkWithTrailingWhitespaceAtEndOfFile() {
assertLinks("body { background: url(foo.png ");
}
@Test
void unterminatedLinksIsSkipped() {
String pathological = "url(x".repeat(200_000);
assertLinks(pathological);
}
private static void assertLinks(String cssContent, String... expectedLinks) {
byte[] input = cssContent.getBytes(UTF_8);
for (int chunkSize : CHUNK_SIZES) {
List<String> links = new ArrayList<>();
byte[] output = parse(input, chunkSize, links);
assertThat(output)
.describedAs("reconstructed output at chunk size " + chunkSize)
.isEqualTo(input);
assertThat(links)
.describedAs("extracted links at chunk size " + chunkSize)
.containsExactly(expectedLinks);
}
}
private static byte[] parse(byte[] input, int chunkSize, List<String> links) {
CssLinkParser parser = new CssLinkParser();
List<CssLinkParser.Token> tokens = new ArrayList<>();
for (int i = 0; i < input.length; i += chunkSize) {
int len = Math.min(chunkSize, input.length - i);
tokens.addAll(parser.feed(input, i, len));
}
tokens.addAll(parser.end());
ByteArrayOutputStream output = new ByteArrayOutputStream();
for (CssLinkParser.Token token : tokens) {
output.write(token.bytes(), 0, token.bytes().length);
if (token.link()) {
links.add(new String(token.bytes(), UTF_8));
}
}
return output.toByteArray();
}
}
@@ -156,15 +156,12 @@ class CssLinkResourceTransformerTests {
@Test // https://github.com/spring-projects/spring-framework/issues/22602
void transformEmptyUrlFunction() throws Exception {
this.request = new MockHttpServletRequest("GET", "/static/empty_url_function.css");
Resource css = getResource("empty_url_function.css");
String expected = """
.fooStyle {
background: transparent url() no-repeat left top;
}""";
Resource expected = getResource("empty_url_function.css");
TransformedResource actual = (TransformedResource) this.transformerChain.transform(this.request, css);
String result = new String(actual.getByteArray(), UTF_8);
assertThat(result).isEqualToNormalizingNewlines(expected);
// An empty url() is not a link at all, so the resource is left untouched,
// exactly like a CSS file that has no link in it at all.
Resource actual = this.transformerChain.transform(this.request, expected);
assertThat(actual).isSameAs(expected);
}
@Test