From 4fbd8c75a538416e07e3ccd87ecbaa0a98219dc5 Mon Sep 17 00:00:00 2001 From: oskar Date: Sun, 1 Mar 2026 13:32:14 +0100 Subject: [PATCH] Add If-Match support with 412 response for precondition failures, improve ETag parsing. Add tests for If-Match --- .../org/juv25d/handler/StaticFileHandler.java | 52 +++++++++++- .../juv25d/handler/StaticFileHandlerTest.java | 80 +++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/juv25d/handler/StaticFileHandler.java b/src/main/java/org/juv25d/handler/StaticFileHandler.java index 70bf35bb..a2762403 100644 --- a/src/main/java/org/juv25d/handler/StaticFileHandler.java +++ b/src/main/java/org/juv25d/handler/StaticFileHandler.java @@ -77,6 +77,40 @@ public static HttpResponse handle(HttpRequest request) { String etag = computeStrongEtag(fileContent); + String ifMatch = getHeaderIgnoreCase(request.headers(), "If-Match"); + if (ifMatch != null && !ifMatch.isBlank() && !etagMatches(ifMatch, etag)) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "text/html; charset=utf-8"); + headers.put("ETag", etag); + headers.put("Cache-Control", "public, max-age=" + MAX_AGE_SECONDS); + + String html = """ + + + + + 412 Precondition Failed + + + +

412 - Precondition Failed

+

The requested resource did not match the supplied If-Match precondition.

+ + + """; + + logger.info("If-Match precondition failed for " + resourcePath + " -> 412 Precondition Failed"); + return new HttpResponse(412, "Precondition Failed", headers, html.getBytes(StandardCharsets.UTF_8)); + } + String ifNoneMatch = getHeaderIgnoreCase(request.headers(), "If-None-Match"); if (etagMatches(ifNoneMatch, etag)) { Map headers = new HashMap<>(); @@ -202,7 +236,16 @@ private static String toHex(byte[] bytes) { @Nullable private static String opaqueTag(@Nullable String etag) { if (etag == null) return null; String e = etag.trim(); - return e.startsWith("W/") ? e.substring(2) : e; + + if (e.startsWith("W/")) { + e = e.substring(2).trim(); + } + + if (e.length() >= 2 && e.startsWith("\"") && e.endsWith("\"")) { + e = e.substring(1, e.length() - 1); + } + + return e; } private static boolean etagMatches(@Nullable String ifNoneMatchHeader, String currentEtag) { @@ -214,10 +257,15 @@ private static boolean etagMatches(@Nullable String ifNoneMatchHeader, String cu return true; } + String current = opaqueTag(currentEtag); + if (current == null) { + return false; + } + String[] parts = value.split(","); for (String part : parts) { String tag = opaqueTag(part); - if (part != null && tag != null && tag.equals(opaqueTag(currentEtag))) { + if (tag != null && tag.equals(current)) { return true; } } diff --git a/src/test/java/org/juv25d/handler/StaticFileHandlerTest.java b/src/test/java/org/juv25d/handler/StaticFileHandlerTest.java index 8c3c77b6..5157f0c5 100644 --- a/src/test/java/org/juv25d/handler/StaticFileHandlerTest.java +++ b/src/test/java/org/juv25d/handler/StaticFileHandlerTest.java @@ -76,6 +76,86 @@ void shouldReturn304WhenIfNoneMatchMatchesEtag() { assertThat(secondResponse.headers()).containsEntry("Cache-Control", "public, max-age=5"); } + @Test + void shouldReturn412WhenIfMatchDoesNotMatch() { + HttpRequest first = createRequest("GET", "/index.html"); + HttpResponse firstResponse = StaticFileHandler.handle(first); + + assertThat(firstResponse.statusCode()).isEqualTo(200); + String etag = firstResponse.headers().get("ETag"); + assertThat(etag).isNotBlank(); + + Map headers = new HashMap<>(); + headers.put("If-Match", "\"definitely-not-the-real-etag\""); + + HttpRequest second = createRequest("GET", "/index.html", headers); + HttpResponse secondResponse = StaticFileHandler.handle(second); + + assertThat(secondResponse.statusCode()).isEqualTo(412); + assertThat(secondResponse.statusText()).isEqualTo("Precondition Failed"); + assertThat(secondResponse.headers()).containsEntry("ETag", etag); + assertThat(secondResponse.headers()).containsEntry("Cache-Control", "public, max-age=5"); + assertThat(secondResponse.body()).isNotEmpty(); + } + + @Test + void shouldReturn200WhenIfMatchMatches() { + HttpRequest first = createRequest("GET", "/index.html"); + HttpResponse firstResponse = StaticFileHandler.handle(first); + + assertThat(firstResponse.statusCode()).isEqualTo(200); + String etag = firstResponse.headers().get("ETag"); + assertThat(etag).isNotBlank(); + + Map headers = new HashMap<>(); + headers.put("If-Match", etag); + + HttpRequest second = createRequest("GET", "/index.html", headers); + HttpResponse secondResponse = StaticFileHandler.handle(second); + + assertThat(secondResponse.statusCode()).isEqualTo(200); + assertThat(secondResponse.statusText()).isEqualTo("OK"); + } + + @Test + void shouldPreferIfMatchOverIfNoneMatch() { + Map headers = new HashMap<>(); + headers.put("If-Match", "\"nope\""); + headers.put("If-None-Match", "*"); + + HttpRequest request = createRequest("GET", "/index.html", headers); + HttpResponse response = StaticFileHandler.handle(request); + + assertThat(response.statusCode()).isEqualTo(412); + assertThat(response.statusText()).isEqualTo("Precondition Failed"); + } + + @Test + void shouldReturn200WhenIfMatchMatchesEvenIfUnquoted() { + HttpRequest first = createRequest("GET", "/index.html"); + HttpResponse firstResponse = StaticFileHandler.handle(first); + + assertThat(firstResponse.statusCode()).isEqualTo(200); + String etag = requireHeader(firstResponse, "ETag"); + assertThat(etag).isNotBlank(); + + String unquoted = etag.replace("\"", ""); + + Map headers = new HashMap<>(); + headers.put("If-Match", unquoted); + + HttpRequest second = createRequest("GET", "/index.html", headers); + HttpResponse secondResponse = StaticFileHandler.handle(second); + + assertThat(secondResponse.statusCode()).isEqualTo(200); + assertThat(secondResponse.statusText()).isEqualTo("OK"); + } + + private String requireHeader(HttpResponse response, String headerName) { + assertThat(response.headers()).containsKey(headerName); + return java.util.Objects.requireNonNull(response.headers().get(headerName)); + } + @Test void shouldReturn404ForNonExistingFile() { HttpRequest request = createRequest("GET", "/nonexistent.html");