From b9600f684b6f303fc438b1fa69f552a7199c2eb3 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 17 Feb 2026 14:11:35 +0100 Subject: [PATCH 01/36] =?UTF-8?q?ska=20vara=20klart=20nu=20har=20gl=C3=B6m?= =?UTF-8?q?t=20att=20commit=20jobbet=20inser=20jag=20dock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/FileCache.java | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/org/example/FileCache.java diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java new file mode 100644 index 00000000..5783ce4d --- /dev/null +++ b/src/main/java/org/example/FileCache.java @@ -0,0 +1,28 @@ +package org.example; + +import java.util.HashMap; +import java.util.Map; + +public class FileCache { + private final Map cache = new HashMap<>(); + + public boolean contains (String key) { + return cache.containsKey(key); + } + + public byte[] get(String key) { + return cache.get(key); + } + + public void put(String key, byte[] value){ + cache.put(key, value); + } + public void clear() { + cache.clear(); + } + + public int size() { + return cache.size(); + } + +} From 07e47bfe4b00b1d7f03c0f443687be07542c4bf9 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 17 Feb 2026 14:12:17 +0100 Subject: [PATCH 02/36] har lagt till i line 20-25 samt line 29 --- src/main/java/org/example/StaticFileHandler.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 95a0b424..d58b9fce 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -12,14 +12,21 @@ public class StaticFileHandler { private static final String WEB_ROOT = "www"; private byte[] fileBytes; + private final FileCache cache = new FileCache(); public StaticFileHandler(){} private void handleGetRequest(String uri) throws IOException { - + if (cache.contains(uri)) { + System.out.println("✓ Cache hit for: " + uri); + fileBytes = cache.get(uri); + return; + } + System.out.println("✗ Cache miss for: " + uri); File file = new File(WEB_ROOT, uri); fileBytes = Files.readAllBytes(file.toPath()); + cache.put(uri, fileBytes); } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ From 8e0ab5096ab4f6fdc47480502e8f2890000c95d5 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 17 Feb 2026 14:19:27 +0100 Subject: [PATCH 03/36] =?UTF-8?q?tester=20f=C3=B6r=20FileCacheTest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/org/example/FileCacheTest.java | 117 +++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/test/java/org/example/FileCacheTest.java diff --git a/src/test/java/org/example/FileCacheTest.java b/src/test/java/org/example/FileCacheTest.java new file mode 100644 index 00000000..0a825aee --- /dev/null +++ b/src/test/java/org/example/FileCacheTest.java @@ -0,0 +1,117 @@ +package org.example; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class FileCacheTest { + private FileCache cache; + + @BeforeEach + void setUp() { + cache = new FileCache(); + } + + @Test + void testPutAndGet() { + byte[] fileContent = "Hello World".getBytes(); + cache.put("test.html", fileContent); + + byte[] retrieved = cache.get("test.html"); + assertThat(retrieved).isEqualTo(fileContent); + } + + @Test + void testContainsReturnsTrueAfterPut() { + byte[] fileContent = "test content".getBytes(); + cache.put("file.txt", fileContent); + + assertThat(cache.contains("file.txt")).isTrue(); + } + + @Test + void testContainsReturnsFalseForNonExistentKey() { + assertThat(cache.contains("nonexistent.html")).isFalse(); + } + + @Test + void testGetReturnsNullForNonExistentKey() { + byte[] result = cache.get("missing.txt"); + assertThat(result).isNull(); + } + + @Test + void testMultipleFiles() { + byte[] content1 = "Content 1".getBytes(); + byte[] content2 = "Content 2".getBytes(); + byte[] content3 = "Content 3".getBytes(); + + cache.put("file1.html", content1); + cache.put("file2.css", content2); + cache.put("file3.js", content3); + + assertThat(cache.get("file1.html")).isEqualTo(content1); + assertThat(cache.get("file2.css")).isEqualTo(content2); + assertThat(cache.get("file3.js")).isEqualTo(content3); + } + + @Test + void testClear() { + cache.put("file1.html", "content1".getBytes()); + cache.put("file2.html", "content2".getBytes()); + assertThat(cache.size()).isEqualTo(2); + + cache.clear(); + + assertThat(cache.size()).isEqualTo(0); + assertThat(cache.contains("file1.html")).isFalse(); + assertThat(cache.contains("file2.html")).isFalse(); + } + + @Test + void testSize() { + assertThat(cache.size()).isEqualTo(0); + + cache.put("file1.html", "content".getBytes()); + assertThat(cache.size()).isEqualTo(1); + + cache.put("file2.html", "content".getBytes()); + assertThat(cache.size()).isEqualTo(2); + + cache.put("file3.html", "content".getBytes()); + assertThat(cache.size()).isEqualTo(3); + } + + @Test + void testOverwriteExistingKey() { + byte[] oldContent = "old".getBytes(); + byte[] newContent = "new".getBytes(); + + cache.put("file.html", oldContent); + assertThat(cache.get("file.html")).isEqualTo(oldContent); + + cache.put("file.html", newContent); + assertThat(cache.get("file.html")).isEqualTo(newContent); + assertThat(cache.size()).isEqualTo(1); + } + + @Test + void testLargeFileContent() { + byte[] largeContent = new byte[10000]; + for (int i = 0; i < largeContent.length; i++) { + largeContent[i] = (byte) (i % 256); + } + + cache.put("large.bin", largeContent); + assertThat(cache.get("large.bin")).isEqualTo(largeContent); + } + + @Test + void testEmptyByteArray() { + byte[] emptyContent = new byte[0]; + cache.put("empty.txt", emptyContent); + + assertThat(cache.contains("empty.txt")).isTrue(); + assertThat(cache.get("empty.txt")).isEmpty(); + } +} From fffdebff315c1edf59f6f640621fda62778c425c Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Fri, 20 Feb 2026 16:48:34 +0100 Subject: [PATCH 04/36] updaterat i StaticFileHandler och skapat CacheFilter --- src/main/java/org/example/CacheFilter.java | 23 ++++++++++++++++ .../java/org/example/StaticFileHandler.java | 27 +++++-------------- 2 files changed, 29 insertions(+), 21 deletions(-) create mode 100644 src/main/java/org/example/CacheFilter.java diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java new file mode 100644 index 00000000..19013322 --- /dev/null +++ b/src/main/java/org/example/CacheFilter.java @@ -0,0 +1,23 @@ +package org.example; + +import java.io.IOException; + +public class CacheFilter { + private final FileCache cache = new FileCache(); + + public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { + if (cache.contains(uri)) { + System.out.println("✓ Cache hit for: " + uri); + return cache.get(uri); + } + System.out.println("✗ Cache miss for: " + uri); + byte[] fileBytes = provider.fetch(uri); + cache.put(uri, fileBytes); + return fileBytes; + } + + @FunctionalInterface + public interface FileProvider { + byte[] fetch(String uri) throws IOException; + } +} diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index d58b9fce..8708bdd4 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -11,32 +11,17 @@ public class StaticFileHandler { private static final String WEB_ROOT = "www"; - private byte[] fileBytes; - private final FileCache cache = new FileCache(); + private final CacheFilter cacheFilter = new CacheFilter(); - public StaticFileHandler(){} - - private void handleGetRequest(String uri) throws IOException { - if (cache.contains(uri)) { - System.out.println("✓ Cache hit for: " + uri); - fileBytes = cache.get(uri); - return; - } - System.out.println("✗ Cache miss for: " + uri); - File file = new File(WEB_ROOT, uri); - fileBytes = Files.readAllBytes(file.toPath()); - - cache.put(uri, fileBytes); - } - - public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ - handleGetRequest(uri); + public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { + byte[] fileBytes = cacheFilter.getOrFetch(uri, + path -> Files.readAllBytes(new File(WEB_ROOT, path).toPath()) + ); + HttpResponseBuilder response = new HttpResponseBuilder(); response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); response.setBody(fileBytes); PrintWriter writer = new PrintWriter(outputStream, true); writer.println(response.build()); - } - } From c69b9dd6f2a31040f5695f43536f3b788bf79a4c Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 17 Feb 2026 14:11:35 +0100 Subject: [PATCH 05/36] =?UTF-8?q?ska=20vara=20klart=20nu=20har=20gl=C3=B6m?= =?UTF-8?q?t=20att=20commit=20jobbet=20inser=20jag=20dock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/FileCache.java | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/org/example/FileCache.java diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java new file mode 100644 index 00000000..5783ce4d --- /dev/null +++ b/src/main/java/org/example/FileCache.java @@ -0,0 +1,28 @@ +package org.example; + +import java.util.HashMap; +import java.util.Map; + +public class FileCache { + private final Map cache = new HashMap<>(); + + public boolean contains (String key) { + return cache.containsKey(key); + } + + public byte[] get(String key) { + return cache.get(key); + } + + public void put(String key, byte[] value){ + cache.put(key, value); + } + public void clear() { + cache.clear(); + } + + public int size() { + return cache.size(); + } + +} From bfa3129765e3e768cbdb2391dbd85e46b44be09b Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sat, 21 Feb 2026 17:15:13 +0100 Subject: [PATCH 06/36] har lagt till i line 20-25 samt line 29 # Conflicts: # src/main/java/org/example/StaticFileHandler.java --- .../java/org/example/StaticFileHandler.java | 59 ++++--------------- 1 file changed, 13 insertions(+), 46 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 756c87fd..95a0b424 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -5,64 +5,31 @@ import java.io.File; import java.io.IOException; import java.io.OutputStream; +import java.io.PrintWriter; import java.nio.file.Files; +import java.util.Map; public class StaticFileHandler { - private final String WEB_ROOT; + private static final String WEB_ROOT = "www"; private byte[] fileBytes; - private int statusCode; - // Constructor for production - public StaticFileHandler() { - WEB_ROOT = "www"; - } - - // Constructor for tests, otherwise the www folder won't be seen - public StaticFileHandler(String webRoot) { - WEB_ROOT = webRoot; - } + public StaticFileHandler(){} private void handleGetRequest(String uri) throws IOException { - // Sanitize URI - int q = uri.indexOf('?'); - if (q >= 0) uri = uri.substring(0, q); - int h = uri.indexOf('#'); - if (h >= 0) uri = uri.substring(0, h); - uri = uri.replace("\0", ""); - if (uri.startsWith("/")) uri = uri.substring(1); - // Path traversal check - File root = new File(WEB_ROOT).getCanonicalFile(); - File file = new File(root, uri).getCanonicalFile(); - if (!file.toPath().startsWith(root.toPath())) { - fileBytes = "403 Forbidden".getBytes(java.nio.charset.StandardCharsets.UTF_8); - statusCode = 403; - return; - } + File file = new File(WEB_ROOT, uri); + fileBytes = Files.readAllBytes(file.toPath()); - // Read file - if (file.isFile()) { - fileBytes = Files.readAllBytes(file.toPath()); - statusCode = 200; - } else { - File errorFile = new File(WEB_ROOT, "pageNotFound.html"); - if (errorFile.isFile()) { - fileBytes = Files.readAllBytes(errorFile.toPath()); - } else { - fileBytes = "404 Not Found".getBytes(java.nio.charset.StandardCharsets.UTF_8); - } - statusCode = 404; - } } - public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { + public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{ handleGetRequest(uri); HttpResponseBuilder response = new HttpResponseBuilder(); - response.setStatusCode(statusCode); - // Use MimeTypeDetector instead of hardcoded text/html - response.setContentTypeFromFilename(uri); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); response.setBody(fileBytes); - outputStream.write(response.build()); - outputStream.flush(); + PrintWriter writer = new PrintWriter(outputStream, true); + writer.println(response.build()); + } -} \ No newline at end of file + +} From 3b34b916c941033ba41fc0744854663b70ee457d Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 17 Feb 2026 14:19:27 +0100 Subject: [PATCH 07/36] =?UTF-8?q?tester=20f=C3=B6r=20FileCacheTest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/org/example/FileCacheTest.java | 117 +++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/test/java/org/example/FileCacheTest.java diff --git a/src/test/java/org/example/FileCacheTest.java b/src/test/java/org/example/FileCacheTest.java new file mode 100644 index 00000000..0a825aee --- /dev/null +++ b/src/test/java/org/example/FileCacheTest.java @@ -0,0 +1,117 @@ +package org.example; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class FileCacheTest { + private FileCache cache; + + @BeforeEach + void setUp() { + cache = new FileCache(); + } + + @Test + void testPutAndGet() { + byte[] fileContent = "Hello World".getBytes(); + cache.put("test.html", fileContent); + + byte[] retrieved = cache.get("test.html"); + assertThat(retrieved).isEqualTo(fileContent); + } + + @Test + void testContainsReturnsTrueAfterPut() { + byte[] fileContent = "test content".getBytes(); + cache.put("file.txt", fileContent); + + assertThat(cache.contains("file.txt")).isTrue(); + } + + @Test + void testContainsReturnsFalseForNonExistentKey() { + assertThat(cache.contains("nonexistent.html")).isFalse(); + } + + @Test + void testGetReturnsNullForNonExistentKey() { + byte[] result = cache.get("missing.txt"); + assertThat(result).isNull(); + } + + @Test + void testMultipleFiles() { + byte[] content1 = "Content 1".getBytes(); + byte[] content2 = "Content 2".getBytes(); + byte[] content3 = "Content 3".getBytes(); + + cache.put("file1.html", content1); + cache.put("file2.css", content2); + cache.put("file3.js", content3); + + assertThat(cache.get("file1.html")).isEqualTo(content1); + assertThat(cache.get("file2.css")).isEqualTo(content2); + assertThat(cache.get("file3.js")).isEqualTo(content3); + } + + @Test + void testClear() { + cache.put("file1.html", "content1".getBytes()); + cache.put("file2.html", "content2".getBytes()); + assertThat(cache.size()).isEqualTo(2); + + cache.clear(); + + assertThat(cache.size()).isEqualTo(0); + assertThat(cache.contains("file1.html")).isFalse(); + assertThat(cache.contains("file2.html")).isFalse(); + } + + @Test + void testSize() { + assertThat(cache.size()).isEqualTo(0); + + cache.put("file1.html", "content".getBytes()); + assertThat(cache.size()).isEqualTo(1); + + cache.put("file2.html", "content".getBytes()); + assertThat(cache.size()).isEqualTo(2); + + cache.put("file3.html", "content".getBytes()); + assertThat(cache.size()).isEqualTo(3); + } + + @Test + void testOverwriteExistingKey() { + byte[] oldContent = "old".getBytes(); + byte[] newContent = "new".getBytes(); + + cache.put("file.html", oldContent); + assertThat(cache.get("file.html")).isEqualTo(oldContent); + + cache.put("file.html", newContent); + assertThat(cache.get("file.html")).isEqualTo(newContent); + assertThat(cache.size()).isEqualTo(1); + } + + @Test + void testLargeFileContent() { + byte[] largeContent = new byte[10000]; + for (int i = 0; i < largeContent.length; i++) { + largeContent[i] = (byte) (i % 256); + } + + cache.put("large.bin", largeContent); + assertThat(cache.get("large.bin")).isEqualTo(largeContent); + } + + @Test + void testEmptyByteArray() { + byte[] emptyContent = new byte[0]; + cache.put("empty.txt", emptyContent); + + assertThat(cache.contains("empty.txt")).isTrue(); + assertThat(cache.get("empty.txt")).isEmpty(); + } +} From b2ef693a040c848c610c0dfd1568e0d1f001acb5 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Fri, 20 Feb 2026 16:48:34 +0100 Subject: [PATCH 08/36] updaterat i StaticFileHandler och skapat CacheFilter # Conflicts: # src/main/java/org/example/StaticFileHandler.java --- src/main/java/org/example/CacheFilter.java | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/java/org/example/CacheFilter.java diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java new file mode 100644 index 00000000..19013322 --- /dev/null +++ b/src/main/java/org/example/CacheFilter.java @@ -0,0 +1,23 @@ +package org.example; + +import java.io.IOException; + +public class CacheFilter { + private final FileCache cache = new FileCache(); + + public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { + if (cache.contains(uri)) { + System.out.println("✓ Cache hit for: " + uri); + return cache.get(uri); + } + System.out.println("✗ Cache miss for: " + uri); + byte[] fileBytes = provider.fetch(uri); + cache.put(uri, fileBytes); + return fileBytes; + } + + @FunctionalInterface + public interface FileProvider { + byte[] fetch(String uri) throws IOException; + } +} From 0a53b7fe8e46e5ee3cb8f1ae21b2d926c4420d6b Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sat, 21 Feb 2026 17:23:23 +0100 Subject: [PATCH 09/36] vad problem med github --- .../java/org/example/StaticFileHandler.java | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 8708bdd4..e80e5a8e 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -10,12 +10,24 @@ import java.util.Map; public class StaticFileHandler { - private static final String WEB_ROOT = "www"; - private final CacheFilter cacheFilter = new CacheFilter(); + private static final String DEFAULT_WEB_ROOT = "www"; + private final String webRoot; + private final CacheFilter cacheFilter; + + // Standardkonstruktor - använder "www" + public StaticFileHandler() { + this(DEFAULT_WEB_ROOT); + } + + // Konstruktor som tar en anpassad webRoot-sökväg (för tester) + public StaticFileHandler(String webRoot) { + this.webRoot = webRoot; + this.cacheFilter = new CacheFilter(); + } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { byte[] fileBytes = cacheFilter.getOrFetch(uri, - path -> Files.readAllBytes(new File(WEB_ROOT, path).toPath()) + path -> Files.readAllBytes(new File(webRoot, path).toPath()) ); HttpResponseBuilder response = new HttpResponseBuilder(); From ddd7b4097b50bc188458b2c5c826aadd638a8605 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sat, 21 Feb 2026 17:26:09 +0100 Subject: [PATCH 10/36] vad problem med github --- .../java/org/example/StaticFileHandler.java | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index e80e5a8e..defaa537 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -26,13 +26,65 @@ public StaticFileHandler(String webRoot) { } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { - byte[] fileBytes = cacheFilter.getOrFetch(uri, - path -> Files.readAllBytes(new File(webRoot, path).toPath()) - ); + try { + // Sanera URI: ta bort frågetecken, hashtaggar, ledande snedstreck och null-bytes + String sanitizedUri = sanitizeUri(uri); + + // Kontrollera för sökvägsgenomgång-attacker + if (isPathTraversal(sanitizedUri)) { + sendErrorResponse(outputStream, 403, "Forbidden"); + return; + } + + byte[] fileBytes = cacheFilter.getOrFetch(sanitizedUri, + path -> Files.readAllBytes(new File(webRoot, path).toPath()) + ); + + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setBody(fileBytes); + PrintWriter writer = new PrintWriter(outputStream, true); + writer.println(response.build()); + + } catch (IOException e) { + // Hantera saknad fil och andra IO-fel + try { + sendErrorResponse(outputStream, 404, "Not Found"); + } catch (IOException ex) { + ex.printStackTrace(); + } + } + } + + private String sanitizeUri(String uri) { + // Ta bort frågesträngar (?) + uri = uri.split("\\?")[0]; + + // Ta bort fragment (#) + uri = uri.split("#")[0]; + + // Ta bort null-bytes + uri = uri.replace("\0", ""); + + // Ta bort ledande snedstreck + while (uri.startsWith("/")) { + uri = uri.substring(1); + } + return uri; + } + + private boolean isPathTraversal(String uri) { + // Kontrollera för kataloggenomgång-försök + return uri.contains("..") || uri.contains("~"); + } + + private void sendErrorResponse(OutputStream outputStream, int statusCode, String statusMessage) throws IOException { HttpResponseBuilder response = new HttpResponseBuilder(); + response.setStatusCode(statusCode); response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); - response.setBody(fileBytes); + String body = "

" + statusCode + " " + statusMessage + "

"; + response.setBody(body); PrintWriter writer = new PrintWriter(outputStream, true); writer.println(response.build()); } From 0c47d58f730e3c17112e22e07ca93166b52b0ee9 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sat, 21 Feb 2026 17:33:26 +0100 Subject: [PATCH 11/36] nu ska det funka --- .../java/org/example/StaticFileHandler.java | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index defaa537..652b36b7 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -5,8 +5,9 @@ import java.io.File; import java.io.IOException; import java.io.OutputStream; -import java.io.PrintWriter; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Map; public class StaticFileHandler { @@ -30,21 +31,21 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep // Sanera URI: ta bort frågetecken, hashtaggar, ledande snedstreck och null-bytes String sanitizedUri = sanitizeUri(uri); - // Kontrollera för sökvägsgenomgång-attacker + // Kontrollera för sökvägsgenomgång-attacker med Path normalisering if (isPathTraversal(sanitizedUri)) { sendErrorResponse(outputStream, 403, "Forbidden"); return; } - byte[] fileBytes = cacheFilter.getOrFetch(sanitizedUri, + byte[] fileBytes = cacheFilter.getOrFetch(sanitizedUri, path -> Files.readAllBytes(new File(webRoot, path).toPath()) ); HttpResponseBuilder response = new HttpResponseBuilder(); - response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setHeaders(Map.of("Content-Type", "text/html; charset=UTF-8")); response.setBody(fileBytes); - PrintWriter writer = new PrintWriter(outputStream, true); - writer.println(response.build()); + outputStream.write(response.build()); + outputStream.flush(); } catch (IOException e) { // Hantera saknad fil och andra IO-fel @@ -76,16 +77,26 @@ private String sanitizeUri(String uri) { private boolean isPathTraversal(String uri) { // Kontrollera för kataloggenomgång-försök - return uri.contains("..") || uri.contains("~"); + try { + // Normalisera sökvägen för att detektera traversal-försök + Path webRootPath = Paths.get(webRoot).toRealPath(); + Path requestedPath = webRootPath.resolve(uri).normalize(); + + // Om den normaliserade sökvägen är utanför webRoot, är det path traversal + return !requestedPath.startsWith(webRootPath); + } catch (IOException e) { + // Om något går fel vid normalisering, behandla det som potentiell path traversal + return true; + } } private void sendErrorResponse(OutputStream outputStream, int statusCode, String statusMessage) throws IOException { HttpResponseBuilder response = new HttpResponseBuilder(); response.setStatusCode(statusCode); - response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setHeaders(Map.of("Content-Type", "text/html; charset=UTF-8")); String body = "

" + statusCode + " " + statusMessage + "

"; response.setBody(body); - PrintWriter writer = new PrintWriter(outputStream, true); - writer.println(response.build()); + outputStream.write(response.build()); + outputStream.flush(); } } From dd0530ae51895de55d8cdba16c2150246c3b5be7 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Mon, 23 Feb 2026 13:48:08 +0100 Subject: [PATCH 12/36] =?UTF-8?q?tog=20i=20bort=20cache=20git=20f=C3=B6r?= =?UTF-8?q?=20missar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 19013322..b3aa1162 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -7,10 +7,9 @@ public class CacheFilter { public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { if (cache.contains(uri)) { - System.out.println("✓ Cache hit for: " + uri); + System.out.println("Cache hit for: " + uri); return cache.get(uri); } - System.out.println("✗ Cache miss for: " + uri); byte[] fileBytes = provider.fetch(uri); cache.put(uri, fileBytes); return fileBytes; From 7f73baf3818889b91a9e45434db3e860b451745c Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Mon, 23 Feb 2026 13:50:10 +0100 Subject: [PATCH 13/36] la till concurrentHashMap i rad 8 --- src/main/java/org/example/FileCache.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index 5783ce4d..7d2a4cf2 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -2,9 +2,10 @@ import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; public class FileCache { - private final Map cache = new HashMap<>(); + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); public boolean contains (String key) { return cache.containsKey(key); From 0a3c08ca155d93b6953c26cd3e84faa57e4a7fb6 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Mon, 23 Feb 2026 15:59:54 +0100 Subject: [PATCH 14/36] refactored caching by removing FileCache completely; implemented an improved CacheFilter with LRU eviction; updated StaticFileHandler to use shared cache; updated and added tests accordingly --- src/main/java/org/example/CacheFilter.java | 173 +++++++++++++++++- src/main/java/org/example/FileCache.java | 29 --- .../java/org/example/StaticFileHandler.java | 34 ++-- src/test/java/org/example/FileCacheTest.java | 117 ------------ .../org/example/StaticFileHandlerTest.java | 106 +++++++++++ 5 files changed, 289 insertions(+), 170 deletions(-) delete mode 100644 src/main/java/org/example/FileCache.java delete mode 100644 src/test/java/org/example/FileCacheTest.java diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index b3aa1162..82d128bb 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -1,20 +1,181 @@ package org.example; import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +/** + * Thread-safe cache filter using ConcurrentHashMap + * Handles caching with LRU eviction for large files + */ public class CacheFilter { - private final FileCache cache = new FileCache(); + private static final int MAX_CACHE_ENTRIES = 100; + private static final long MAX_CACHE_BYTES = 50 * 1024 * 1024; // 50MB + // Lock-free concurrent cache + private final ConcurrentHashMap cache = + new ConcurrentHashMap<>(16, 0.75f, 16); // 16 segments för concurrency + + private final AtomicLong currentBytes = new AtomicLong(0); + + /** + * Cache entry med metadata för LRU tracking + */ + private static class CacheEntry { + final byte[] data; + final AtomicLong lastAccessTime; + final AtomicLong accessCount; + + CacheEntry(byte[] data) { + this.data = data; + this.lastAccessTime = new AtomicLong(System.currentTimeMillis()); + this.accessCount = new AtomicLong(0); + } + + void recordAccess() { + accessCount.incrementAndGet(); + lastAccessTime.set(System.currentTimeMillis()); + } + } + + /** + * Hämta från cache eller fetch från provider (thread-safe) + */ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { - if (cache.contains(uri)) { - System.out.println("Cache hit for: " + uri); - return cache.get(uri); + // Kontrollera cache (lock-free read) + if (cache.containsKey(uri)) { + CacheEntry entry = cache.get(uri); + if (entry != null) { + entry.recordAccess(); + System.out.println(" Cache hit for: " + uri); + return entry.data; + } } + + // Cache miss - fetch från provider + System.out.println(" Cache miss for: " + uri); byte[] fileBytes = provider.fetch(uri); - cache.put(uri, fileBytes); + + // Lägg till i cache + addToCache(uri, fileBytes); + return fileBytes; } - + + /** + * Lägg till i cache med eviction om nödvändigt (thread-safe) + */ + private synchronized void addToCache(String uri, byte[] data) { + // Kontrollera om vi måste evicta innan vi lägger till + while (shouldEvict(data)) { + evictLeastRecentlyUsed(); + } + + CacheEntry newEntry = new CacheEntry(data); + CacheEntry oldEntry = cache.put(uri, newEntry); + + // Uppdatera byte-count + if (oldEntry != null) { + currentBytes.addAndGet(-oldEntry.data.length); + } + currentBytes.addAndGet(data.length); + } + + /** + * Kontrollera om vi behöver evicta + */ + private boolean shouldEvict(byte[] newValue) { + return cache.size() >= MAX_CACHE_ENTRIES || + (currentBytes.get() + newValue.length) > MAX_CACHE_BYTES; + } + + /** + * Evicta minst nyligen använd entry + */ + private void evictLeastRecentlyUsed() { + if (cache.isEmpty()) return; + + // Hitta entry med minst senaste access + String keyToRemove = cache.entrySet().stream() + .min((a, b) -> Long.compare( + a.getValue().lastAccessTime.get(), + b.getValue().lastAccessTime.get() + )) + .map(java.util.Map.Entry::getKey) + .orElse(null); + + if (keyToRemove != null) { + CacheEntry removed = cache.remove(keyToRemove); + if (removed != null) { + currentBytes.addAndGet(-removed.data.length); + System.out.println("✗ Evicted from cache: " + keyToRemove + + " (accesses: " + removed.accessCount.get() + ")"); + } + } + } + + // Diagnostik-metoder + public int getCacheSize() { + return cache.size(); + } + + public long getCurrentBytes() { + return currentBytes.get(); + } + + public long getMaxBytes() { + return MAX_CACHE_BYTES; + } + + public double getCacheUtilization() { + return (double) currentBytes.get() / MAX_CACHE_BYTES * 100; + } + + public void clearCache() { + cache.clear(); + currentBytes.set(0); + } + + public CacheStats getStats() { + long totalAccesses = cache.values().stream() + .mapToLong(e -> e.accessCount.get()) + .sum(); + + return new CacheStats( + cache.size(), + currentBytes.get(), + MAX_CACHE_ENTRIES, + MAX_CACHE_BYTES, + totalAccesses + ); + } + + // Stats-klass + public static class CacheStats { + public final int entries; + public final long bytes; + public final int maxEntries; + public final long maxBytes; + public final long totalAccesses; + + CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { + this.entries = entries; + this.bytes = bytes; + this.maxEntries = maxEntries; + this.maxBytes = maxBytes; + this.totalAccesses = totalAccesses; + } + + @Override + public String toString() { + return String.format( + "CacheStats{entries=%d/%d, bytes=%d/%d, utilization=%.1f%%, accesses=%d}", + entries, maxEntries, bytes, maxBytes, + (double) bytes / maxBytes * 100, totalAccesses + ); + } + } + @FunctionalInterface public interface FileProvider { byte[] fetch(String uri) throws IOException; diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java deleted file mode 100644 index 7d2a4cf2..00000000 --- a/src/main/java/org/example/FileCache.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.example; - -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -public class FileCache { - private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); - - public boolean contains (String key) { - return cache.containsKey(key); - } - - public byte[] get(String key) { - return cache.get(key); - } - - public void put(String key, byte[] value){ - cache.put(key, value); - } - public void clear() { - cache.clear(); - } - - public int size() { - return cache.size(); - } - -} diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 652b36b7..ee5030c5 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -12,32 +12,32 @@ public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; + + // ✅ EN shared cache för alla threads + private static final CacheFilter SHARED_CACHE = new CacheFilter(); + private final String webRoot; - private final CacheFilter cacheFilter; - // Standardkonstruktor - använder "www" public StaticFileHandler() { this(DEFAULT_WEB_ROOT); } - // Konstruktor som tar en anpassad webRoot-sökväg (för tester) + public StaticFileHandler(String webRoot) { this.webRoot = webRoot; - this.cacheFilter = new CacheFilter(); } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { try { - // Sanera URI: ta bort frågetecken, hashtaggar, ledande snedstreck och null-bytes String sanitizedUri = sanitizeUri(uri); - // Kontrollera för sökvägsgenomgång-attacker med Path normalisering if (isPathTraversal(sanitizedUri)) { sendErrorResponse(outputStream, 403, "Forbidden"); return; } - byte[] fileBytes = cacheFilter.getOrFetch(sanitizedUri, + // Använd shared cache istället för ny instans + byte[] fileBytes = SHARED_CACHE.getOrFetch(sanitizedUri, path -> Files.readAllBytes(new File(webRoot, path).toPath()) ); @@ -48,7 +48,6 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep outputStream.flush(); } catch (IOException e) { - // Hantera saknad fil och andra IO-fel try { sendErrorResponse(outputStream, 404, "Not Found"); } catch (IOException ex) { @@ -58,16 +57,10 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep } private String sanitizeUri(String uri) { - // Ta bort frågesträngar (?) uri = uri.split("\\?")[0]; - - // Ta bort fragment (#) uri = uri.split("#")[0]; - - // Ta bort null-bytes uri = uri.replace("\0", ""); - // Ta bort ledande snedstreck while (uri.startsWith("/")) { uri = uri.substring(1); } @@ -76,16 +69,12 @@ private String sanitizeUri(String uri) { } private boolean isPathTraversal(String uri) { - // Kontrollera för kataloggenomgång-försök try { - // Normalisera sökvägen för att detektera traversal-försök Path webRootPath = Paths.get(webRoot).toRealPath(); Path requestedPath = webRootPath.resolve(uri).normalize(); - // Om den normaliserade sökvägen är utanför webRoot, är det path traversal return !requestedPath.startsWith(webRootPath); } catch (IOException e) { - // Om något går fel vid normalisering, behandla det som potentiell path traversal return true; } } @@ -99,4 +88,13 @@ private void sendErrorResponse(OutputStream outputStream, int statusCode, String outputStream.write(response.build()); outputStream.flush(); } + + //Diagnostik-metod + public static CacheFilter.CacheStats getCacheStats() { + return SHARED_CACHE.getStats(); + } + + public static void clearCache() { + SHARED_CACHE.clearCache(); + } } diff --git a/src/test/java/org/example/FileCacheTest.java b/src/test/java/org/example/FileCacheTest.java deleted file mode 100644 index 0a825aee..00000000 --- a/src/test/java/org/example/FileCacheTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.example; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.*; - -class FileCacheTest { - private FileCache cache; - - @BeforeEach - void setUp() { - cache = new FileCache(); - } - - @Test - void testPutAndGet() { - byte[] fileContent = "Hello World".getBytes(); - cache.put("test.html", fileContent); - - byte[] retrieved = cache.get("test.html"); - assertThat(retrieved).isEqualTo(fileContent); - } - - @Test - void testContainsReturnsTrueAfterPut() { - byte[] fileContent = "test content".getBytes(); - cache.put("file.txt", fileContent); - - assertThat(cache.contains("file.txt")).isTrue(); - } - - @Test - void testContainsReturnsFalseForNonExistentKey() { - assertThat(cache.contains("nonexistent.html")).isFalse(); - } - - @Test - void testGetReturnsNullForNonExistentKey() { - byte[] result = cache.get("missing.txt"); - assertThat(result).isNull(); - } - - @Test - void testMultipleFiles() { - byte[] content1 = "Content 1".getBytes(); - byte[] content2 = "Content 2".getBytes(); - byte[] content3 = "Content 3".getBytes(); - - cache.put("file1.html", content1); - cache.put("file2.css", content2); - cache.put("file3.js", content3); - - assertThat(cache.get("file1.html")).isEqualTo(content1); - assertThat(cache.get("file2.css")).isEqualTo(content2); - assertThat(cache.get("file3.js")).isEqualTo(content3); - } - - @Test - void testClear() { - cache.put("file1.html", "content1".getBytes()); - cache.put("file2.html", "content2".getBytes()); - assertThat(cache.size()).isEqualTo(2); - - cache.clear(); - - assertThat(cache.size()).isEqualTo(0); - assertThat(cache.contains("file1.html")).isFalse(); - assertThat(cache.contains("file2.html")).isFalse(); - } - - @Test - void testSize() { - assertThat(cache.size()).isEqualTo(0); - - cache.put("file1.html", "content".getBytes()); - assertThat(cache.size()).isEqualTo(1); - - cache.put("file2.html", "content".getBytes()); - assertThat(cache.size()).isEqualTo(2); - - cache.put("file3.html", "content".getBytes()); - assertThat(cache.size()).isEqualTo(3); - } - - @Test - void testOverwriteExistingKey() { - byte[] oldContent = "old".getBytes(); - byte[] newContent = "new".getBytes(); - - cache.put("file.html", oldContent); - assertThat(cache.get("file.html")).isEqualTo(oldContent); - - cache.put("file.html", newContent); - assertThat(cache.get("file.html")).isEqualTo(newContent); - assertThat(cache.size()).isEqualTo(1); - } - - @Test - void testLargeFileContent() { - byte[] largeContent = new byte[10000]; - for (int i = 0; i < largeContent.length; i++) { - largeContent[i] = (byte) (i % 256); - } - - cache.put("large.bin", largeContent); - assertThat(cache.get("large.bin")).isEqualTo(largeContent); - } - - @Test - void testEmptyByteArray() { - byte[] emptyContent = new byte[0]; - cache.put("empty.txt", emptyContent); - - assertThat(cache.contains("empty.txt")).isTrue(); - assertThat(cache.get("empty.txt")).isEmpty(); - } -} diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 1fe4893b..c74f59a9 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -1,5 +1,6 @@ package org.example; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; @@ -10,6 +11,8 @@ import java.nio.file.Files; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; + /** * Unit test class for verifying the behavior of the StaticFileHandler class. @@ -25,12 +28,115 @@ */ class StaticFileHandlerTest { + private StaticFileHandler handler; + //Junit creates a temporary folder which can be filled with temporary files that gets removed after tests @TempDir Path tempDir; + @BeforeEach + void setUp() { + // Rensa cache innan varje test för clean state + StaticFileHandler.clearCache(); + } + @Test + void testCaching_HitOnSecondRequest() throws IOException { + // Arrange + Files.writeString(tempDir.resolve("cached.html"), "Content"); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + + // Act - Första anropet (cache miss) + handler.sendGetRequest(new ByteArrayOutputStream(), "cached.html"); + int sizeAfterFirst = StaticFileHandler.getCacheStats().entries; + + // Act - Andra anropet (cache hit) + handler.sendGetRequest(new ByteArrayOutputStream(), "cached.html"); + int sizeAfterSecond = StaticFileHandler.getCacheStats().entries; + + // Assert - Cache ska innehålla samma entry + assertThat(sizeAfterFirst).isEqualTo(1); + assertThat(sizeAfterSecond).isEqualTo(1); + } + + @Test + void testSanitization_QueryString() throws IOException { + // Arrange + Files.writeString(tempDir.resolve("index.html"), "Home"); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Act - URI med query string + handler.sendGetRequest(output, "index.html?foo=bar"); + + // Assert + assertThat(output.toString()).contains("HTTP/1.1 200"); + } + + @Test + void testSanitization_LeadingSlash() throws IOException { + // Arrange + Files.writeString(tempDir.resolve("page.html"), "Page"); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(output, "/page.html"); + + // Assert + assertThat(output.toString()).contains("HTTP/1.1 200"); + } + + @Test + void testSanitization_NullBytes() throws IOException { + // Arrange + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(output, "file.html\0../../secret"); + + // Assert + assertThat(output.toString()).contains("HTTP/1.1 404"); + } + + @Test + void testConcurrent_MultipleReads() throws InterruptedException, IOException { + // Arrange + Files.writeString(tempDir.resolve("shared.html"), "Data"); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + + // Förvärmning + handler.sendGetRequest(new ByteArrayOutputStream(), "shared.html"); + + // Act - 10 trådar läser samma fil 50 gånger varje + Thread[] threads = new Thread[10]; + for (int i = 0; i < 10; i++) { + threads[i] = new Thread(() -> { + try { + for (int j = 0; j < 50; j++) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + handler.sendGetRequest(out, "shared.html"); + assertThat(out.toString()).contains("200"); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + threads[i].start(); + } + + // Vänta på alla trådar + for (Thread t : threads) { + t.join(); + } + + // Assert - Cache ska bara ha EN entry + assertThat(StaticFileHandler.getCacheStats().entries).isEqualTo(1); + } + +@Test void test_file_that_exists_should_return_200() throws IOException { //Arrange Path testFile = tempDir.resolve("test.html"); // Defines the path in the temp directory From 56cc3e848ce3ed4a0ad502260b74a40cb4b735b2 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 24 Feb 2026 12:54:56 +0100 Subject: [PATCH 15/36] removed extra whitespace in MAX_CACHE_BYTES declaration --- src/main/java/org/example/CacheFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 82d128bb..330c0b24 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -10,7 +10,7 @@ */ public class CacheFilter { private static final int MAX_CACHE_ENTRIES = 100; - private static final long MAX_CACHE_BYTES = 50 * 1024 * 1024; // 50MB + private static final long MAX_CACHE_BYTES = 50 * 1024 * 1024;// 50MB // Lock-free concurrent cache private final ConcurrentHashMap cache = From d20bb6fc92e1c31457df0eac8a0d4cae99e6f222 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Tue, 24 Feb 2026 12:57:50 +0100 Subject: [PATCH 16/36] removed things not used in code --- src/main/java/org/example/CacheFilter.java | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 330c0b24..ad7504fd 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -115,22 +115,6 @@ private void evictLeastRecentlyUsed() { } // Diagnostik-metoder - public int getCacheSize() { - return cache.size(); - } - - public long getCurrentBytes() { - return currentBytes.get(); - } - - public long getMaxBytes() { - return MAX_CACHE_BYTES; - } - - public double getCacheUtilization() { - return (double) currentBytes.get() / MAX_CACHE_BYTES * 100; - } - public void clearCache() { cache.clear(); currentBytes.set(0); From 3231ee1b851c2f770c0ca21c286254bfd1d3841f Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 11:38:35 +0100 Subject: [PATCH 17/36] =?UTF-8?q?=C3=A4ndringar=20som=20kodrabbit=20f?= =?UTF-8?q?=C3=B6rstog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 129 +++++++++++++-------- 1 file changed, 80 insertions(+), 49 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index ad7504fd..d9ed9a34 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -1,3 +1,4 @@ + package org.example; import java.io.IOException; @@ -11,12 +12,13 @@ public class CacheFilter { private static final int MAX_CACHE_ENTRIES = 100; private static final long MAX_CACHE_BYTES = 50 * 1024 * 1024;// 50MB - + // Lock-free concurrent cache - private final ConcurrentHashMap cache = - new ConcurrentHashMap<>(16, 0.75f, 16); // 16 segments för concurrency - + private final ConcurrentHashMap cache = + new ConcurrentHashMap<>(16, 0.75f, 16); + private final AtomicLong currentBytes = new AtomicLong(0); + private final Object writeLock = new Object(); // För atomära operationer /** * Cache entry med metadata för LRU tracking @@ -40,40 +42,65 @@ void recordAccess() { /** * Hämta från cache eller fetch från provider (thread-safe) + * Använder double-checked locking för att undvika TOCTOU-race */ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { - // Kontrollera cache (lock-free read) - if (cache.containsKey(uri)) { - CacheEntry entry = cache.get(uri); + // First check - lock-free read (snabb väg) + CacheEntry entry = cache.get(uri); + if (entry != null) { + entry.recordAccess(); + System.out.println("✓ Cache hit for: " + uri); + return entry.data; + } + + // Cache miss - fetch från provider under lock + synchronized (writeLock) { + // Second check - verifierar att ingen annan tråd fetchade medan vi väntade på lock + entry = cache.get(uri); if (entry != null) { entry.recordAccess(); - System.out.println(" Cache hit for: " + uri); + System.out.println("✓ Cache hit for: " + uri + " (from concurrent fetch)"); return entry.data; } - } - // Cache miss - fetch från provider - System.out.println(" Cache miss for: " + uri); - byte[] fileBytes = provider.fetch(uri); - - // Lägg till i cache - addToCache(uri, fileBytes); - - return fileBytes; + // Fetch och cachelagra + System.out.println("✗ Cache miss for: " + uri); + byte[] fileBytes = provider.fetch(uri); + + if (fileBytes != null) { + addToCacheUnsafe(uri, fileBytes); + } + + return fileBytes; + } } /** - * Lägg till i cache med eviction om nödvändigt (thread-safe) + * Lägg till i cache med eviction om nödvändigt (MÅSTE VARA UNDER LOCK) */ - private synchronized void addToCache(String uri, byte[] data) { - // Kontrollera om vi måste evicta innan vi lägger till - while (shouldEvict(data)) { - evictLeastRecentlyUsed(); + private void addToCacheUnsafe(String uri, byte[] data) { + // Guard mot oversized entries som kan blockera eviction + if (data.length > MAX_CACHE_BYTES) { + System.out.println("⚠️ Skipping cache for oversized file: " + uri + + " (" + (data.length / 1024 / 1024) + "MB > " + + (MAX_CACHE_BYTES / 1024 / 1024) + "MB)"); + return; + } + + // Evicta medan nödvändigt (med empty-check för infinite loop) + while (shouldEvict(data) && !cache.isEmpty()) { + evictLeastRecentlyUsedUnsafe(); + } + + // Om cache fortfarande är full efter eviction, hoppa över + if (shouldEvict(data)) { + System.out.println("⚠️ Cache full, skipping: " + uri); + return; } CacheEntry newEntry = new CacheEntry(data); CacheEntry oldEntry = cache.put(uri, newEntry); - + // Uppdatera byte-count if (oldEntry != null) { currentBytes.addAndGet(-oldEntry.data.length); @@ -85,52 +112,56 @@ private synchronized void addToCache(String uri, byte[] data) { * Kontrollera om vi behöver evicta */ private boolean shouldEvict(byte[] newValue) { - return cache.size() >= MAX_CACHE_ENTRIES || - (currentBytes.get() + newValue.length) > MAX_CACHE_BYTES; + return cache.size() >= MAX_CACHE_ENTRIES || + (currentBytes.get() + newValue.length) > MAX_CACHE_BYTES; } /** - * Evicta minst nyligen använd entry + * Evicta minst nyligen använd entry (MÅSTE VARA UNDER LOCK) */ - private void evictLeastRecentlyUsed() { + private void evictLeastRecentlyUsedUnsafe() { if (cache.isEmpty()) return; // Hitta entry med minst senaste access String keyToRemove = cache.entrySet().stream() - .min((a, b) -> Long.compare( - a.getValue().lastAccessTime.get(), - b.getValue().lastAccessTime.get() - )) - .map(java.util.Map.Entry::getKey) - .orElse(null); + .min((a, b) -> Long.compare( + a.getValue().lastAccessTime.get(), + b.getValue().lastAccessTime.get() + )) + .map(java.util.Map.Entry::getKey) + .orElse(null); if (keyToRemove != null) { CacheEntry removed = cache.remove(keyToRemove); if (removed != null) { currentBytes.addAndGet(-removed.data.length); - System.out.println("✗ Evicted from cache: " + keyToRemove + - " (accesses: " + removed.accessCount.get() + ")"); + System.out.println("✗ Evicted from cache: " + keyToRemove + + " (accesses: " + removed.accessCount.get() + ")"); } } } - // Diagnostik-metoder + /** + * Rensa cache atomärt + */ public void clearCache() { - cache.clear(); - currentBytes.set(0); + synchronized (writeLock) { + cache.clear(); + currentBytes.set(0); + } } public CacheStats getStats() { long totalAccesses = cache.values().stream() - .mapToLong(e -> e.accessCount.get()) - .sum(); - + .mapToLong(e -> e.accessCount.get()) + .sum(); + return new CacheStats( - cache.size(), - currentBytes.get(), - MAX_CACHE_ENTRIES, - MAX_CACHE_BYTES, - totalAccesses + cache.size(), + currentBytes.get(), + MAX_CACHE_ENTRIES, + MAX_CACHE_BYTES, + totalAccesses ); } @@ -153,9 +184,9 @@ public static class CacheStats { @Override public String toString() { return String.format( - "CacheStats{entries=%d/%d, bytes=%d/%d, utilization=%.1f%%, accesses=%d}", - entries, maxEntries, bytes, maxBytes, - (double) bytes / maxBytes * 100, totalAccesses + "CacheStats{entries=%d/%d, bytes=%d/%d, utilization=%.1f%%, accesses=%d}", + entries, maxEntries, bytes, maxBytes, + (double) bytes / maxBytes * 100, totalAccesses ); } } From 99f5fd7765cc8c2e7a1376a69832ba81b52f6a96 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 11:39:37 +0100 Subject: [PATCH 18/36] =?UTF-8?q?=C3=A4ndringar=20som=20kodrabbit=20f?= =?UTF-8?q?=C3=B6rstog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index d9ed9a34..bafecf4e 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -2,6 +2,7 @@ package org.example; import java.io.IOException; +import java.util.Comparator; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -124,10 +125,7 @@ private void evictLeastRecentlyUsedUnsafe() { // Hitta entry med minst senaste access String keyToRemove = cache.entrySet().stream() - .min((a, b) -> Long.compare( - a.getValue().lastAccessTime.get(), - b.getValue().lastAccessTime.get() - )) + .min(Comparator.comparingLong(e -> e.getValue().lastAccessTime.get())) .map(java.util.Map.Entry::getKey) .orElse(null); @@ -141,6 +139,7 @@ private void evictLeastRecentlyUsedUnsafe() { } } + /** * Rensa cache atomärt */ From 04cba977995693cbfe67312fafa5d03d8258369f Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 11:49:45 +0100 Subject: [PATCH 19/36] =?UTF-8?q?=C3=A4ndringar=20som=20kodrabbit=20f?= =?UTF-8?q?=C3=B6rstog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index bafecf4e..2a77e23a 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -139,7 +139,6 @@ private void evictLeastRecentlyUsedUnsafe() { } } - /** * Rensa cache atomärt */ From c1586778c43720748f3c9d4a160b99d1baaf554f Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 11:56:02 +0100 Subject: [PATCH 20/36] refactored StaticFileHandler to improve error handling, enhance sanitization logic, and optimize shared cache usage; streamlined test cases for clarity --- .../java/org/example/StaticFileHandler.java | 52 ++++++++-------- .../org/example/StaticFileHandlerTest.java | 59 +++++++------------ 2 files changed, 47 insertions(+), 64 deletions(-) diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index ee5030c5..c549a735 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -13,7 +13,7 @@ public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; - // ✅ EN shared cache för alla threads + // EN shared cache för alla threads private static final CacheFilter SHARED_CACHE = new CacheFilter(); private final String webRoot; @@ -28,46 +28,44 @@ public StaticFileHandler(String webRoot) { } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { + String sanitizedUri = sanitizeUri(uri); + + if (isPathTraversal(sanitizedUri)) { + sendErrorResponse(outputStream, 403, "Forbidden"); + return; + } + try { - String sanitizedUri = sanitizeUri(uri); - - if (isPathTraversal(sanitizedUri)) { - sendErrorResponse(outputStream, 403, "Forbidden"); - return; - } - - // Använd shared cache istället för ny instans byte[] fileBytes = SHARED_CACHE.getOrFetch(sanitizedUri, - path -> Files.readAllBytes(new File(webRoot, path).toPath()) + path -> Files.readAllBytes(new File(webRoot, path).toPath()) ); - + HttpResponseBuilder response = new HttpResponseBuilder(); - response.setHeaders(Map.of("Content-Type", "text/html; charset=UTF-8")); + response.setContentTypeFromFilename(sanitizedUri); response.setBody(fileBytes); outputStream.write(response.build()); outputStream.flush(); - + } catch (IOException e) { - try { - sendErrorResponse(outputStream, 404, "Not Found"); - } catch (IOException ex) { - ex.printStackTrace(); - } + sendErrorResponse(outputStream, 404, "Not Found"); } } private String sanitizeUri(String uri) { - uri = uri.split("\\?")[0]; - uri = uri.split("#")[0]; - uri = uri.replace("\0", ""); - - while (uri.startsWith("/")) { - uri = uri.substring(1); - } - + // Entydlig: ta bort query string och fragment + int queryIndex = uri.indexOf('?'); + int fragmentIndex = uri.indexOf('#'); + int endIndex = Math.min( + queryIndex > 0 ? queryIndex : uri.length(), + fragmentIndex > 0 ? fragmentIndex : uri.length() + ); + + uri = uri.substring(0, endIndex) + .replace("\0", "") + .replaceAll("^/+", ""); // Bort med leading slashes + return uri; } - private boolean isPathTraversal(String uri) { try { Path webRootPath = Paths.get(webRoot).toRealPath(); diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index c74f59a9..262ecc25 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -28,7 +28,17 @@ */ class StaticFileHandlerTest { - private StaticFileHandler handler; + private StaticFileHandler createHandler(){ + return new StaticFileHandler(tempDir.toString()); + } + + private String sendRequest(String uri) throws IOException { + StaticFileHandler handler = createHandler(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + handler.sendGetRequest(output, uri); + return output.toString(); + } + //Junit creates a temporary folder which can be filled with temporary files that gets removed after tests @TempDir @@ -45,60 +55,35 @@ void setUp() { void testCaching_HitOnSecondRequest() throws IOException { // Arrange Files.writeString(tempDir.resolve("cached.html"), "Content"); - StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - // Act - Första anropet (cache miss) - handler.sendGetRequest(new ByteArrayOutputStream(), "cached.html"); + // Act + sendRequest("cached.html"); int sizeAfterFirst = StaticFileHandler.getCacheStats().entries; - - // Act - Andra anropet (cache hit) - handler.sendGetRequest(new ByteArrayOutputStream(), "cached.html"); + sendRequest("cached.html"); int sizeAfterSecond = StaticFileHandler.getCacheStats().entries; - // Assert - Cache ska innehålla samma entry - assertThat(sizeAfterFirst).isEqualTo(1); - assertThat(sizeAfterSecond).isEqualTo(1); + // Assert + assertThat(sizeAfterFirst).isEqualTo(sizeAfterSecond).isEqualTo(1); } + @Test void testSanitization_QueryString() throws IOException { - // Arrange Files.writeString(tempDir.resolve("index.html"), "Home"); - StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - // Act - URI med query string - handler.sendGetRequest(output, "index.html?foo=bar"); - - // Assert - assertThat(output.toString()).contains("HTTP/1.1 200"); + assertThat(sendRequest("index.html?foo=bar")).contains("HTTP/1.1 200"); } + @Test void testSanitization_LeadingSlash() throws IOException { - // Arrange Files.writeString(tempDir.resolve("page.html"), "Page"); - StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - // Act - handler.sendGetRequest(output, "/page.html"); - - // Assert - assertThat(output.toString()).contains("HTTP/1.1 200"); + assertThat(sendRequest("/page.html")).contains("HTTP/1.1 200"); } + @Test void testSanitization_NullBytes() throws IOException { - // Arrange - StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - // Act - handler.sendGetRequest(output, "file.html\0../../secret"); - - // Assert - assertThat(output.toString()).contains("HTTP/1.1 404"); + assertThat(sendRequest("file.html\0../../secret")).contains("HTTP/1.1 404"); } @Test From 811ccce7629b103a117754d30e6120bfe190029a Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 12:33:19 +0100 Subject: [PATCH 21/36] =?UTF-8?q?fixa=20lite=20och=20hade=20gl=C3=B6mt=20a?= =?UTF-8?q?tt=20commit=20vissa=20saker=20efter=20=C3=A4ndringar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 22 ++++--- src/main/java/org/example/FileCache.java | 66 +++++++++++++++++++ .../java/org/example/StaticFileHandler.java | 42 ++++++++++-- .../org/example/StaticFileHandlerTest.java | 19 ++++-- 4 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 src/main/java/org/example/FileCache.java diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 2a77e23a..22958a84 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -5,12 +5,16 @@ import java.util.Comparator; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; +import java.util.logging.Level; /** - * Thread-safe cache filter using ConcurrentHashMap + * Thread-safe in-memory cache filter using ConcurrentHashMap * Handles caching with LRU eviction for large files + * Implements the FileCache interface for pluggable cache implementations */ -public class CacheFilter { +public class CacheFilter implements FileCache { + private static final Logger LOGGER = Logger.getLogger(CacheFilter.class.getName()); private static final int MAX_CACHE_ENTRIES = 100; private static final long MAX_CACHE_BYTES = 50 * 1024 * 1024;// 50MB @@ -45,12 +49,13 @@ void recordAccess() { * Hämta från cache eller fetch från provider (thread-safe) * Använder double-checked locking för att undvika TOCTOU-race */ + @Override public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { // First check - lock-free read (snabb väg) CacheEntry entry = cache.get(uri); if (entry != null) { entry.recordAccess(); - System.out.println("✓ Cache hit for: " + uri); + LOGGER.log(Level.FINE, "✓ Cache hit for: " + uri); return entry.data; } @@ -60,12 +65,12 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { entry = cache.get(uri); if (entry != null) { entry.recordAccess(); - System.out.println("✓ Cache hit for: " + uri + " (from concurrent fetch)"); + LOGGER.log(Level.FINE, "✓ Cache hit for: " + uri + " (from concurrent fetch)"); return entry.data; } // Fetch och cachelagra - System.out.println("✗ Cache miss for: " + uri); + LOGGER.log(Level.FINE, "✗ Cache miss for: " + uri); byte[] fileBytes = provider.fetch(uri); if (fileBytes != null) { @@ -82,7 +87,7 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { private void addToCacheUnsafe(String uri, byte[] data) { // Guard mot oversized entries som kan blockera eviction if (data.length > MAX_CACHE_BYTES) { - System.out.println("⚠️ Skipping cache for oversized file: " + uri + + LOGGER.log(Level.WARNING, "⚠️ Skipping cache for oversized file: " + uri + " (" + (data.length / 1024 / 1024) + "MB > " + (MAX_CACHE_BYTES / 1024 / 1024) + "MB)"); return; @@ -95,7 +100,7 @@ private void addToCacheUnsafe(String uri, byte[] data) { // Om cache fortfarande är full efter eviction, hoppa över if (shouldEvict(data)) { - System.out.println("⚠️ Cache full, skipping: " + uri); + LOGGER.log(Level.WARNING, "⚠️ Cache full, skipping: " + uri); return; } @@ -133,7 +138,7 @@ private void evictLeastRecentlyUsedUnsafe() { CacheEntry removed = cache.remove(keyToRemove); if (removed != null) { currentBytes.addAndGet(-removed.data.length); - System.out.println("✗ Evicted from cache: " + keyToRemove + + LOGGER.log(Level.FINE, "✗ Evicted from cache: " + keyToRemove + " (accesses: " + removed.accessCount.get() + ")"); } } @@ -142,6 +147,7 @@ private void evictLeastRecentlyUsedUnsafe() { /** * Rensa cache atomärt */ + @Override public void clearCache() { synchronized (writeLock) { cache.clear(); diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java new file mode 100644 index 00000000..8547b388 --- /dev/null +++ b/src/main/java/org/example/FileCache.java @@ -0,0 +1,66 @@ +package org.example; + +import java.io.IOException; + +/** + * Interface for pluggable file caching implementations. + * Allows for different cache backends (in-memory, Redis, Caffeine, etc.) + */ +public interface FileCache { + /** + * Retrieves cached file or fetches from provider if not cached. + * Implementation must be thread-safe. + * + * @param cacheKey Unique cache key (typically includes webRoot + uri) + * @param provider Function to fetch file bytes if not cached + * @return File bytes, or null if file not found + * @throws IOException if fetch fails + */ + byte[] getOrFetch(String cacheKey, FileProvider provider) throws IOException; + + /** + * Clear all cached entries. + */ + void clearCache(); + + /** + * Get cache statistics. + */ + CacheStats getStats(); + + /** + * Functional interface for file fetching providers. + */ + @FunctionalInterface + interface FileProvider { + byte[] fetch(String uri) throws IOException; + } + + /** + * Cache statistics record. + */ + class CacheStats { + public final int entries; + public final long bytes; + public final int maxEntries; + public final long maxBytes; + public final long totalAccesses; + + public CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { + this.entries = entries; + this.bytes = bytes; + this.maxEntries = maxEntries; + this.maxBytes = maxBytes; + this.totalAccesses = totalAccesses; + } + + @Override + public String toString() { + return String.format( + "CacheStats{entries=%d/%d, bytes=%d/%d, utilization=%.1f%%, accesses=%d}", + entries, maxEntries, bytes, maxBytes, + (double) bytes / maxBytes * 100, totalAccesses + ); + } + } +} diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index c549a735..90641768 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -5,16 +5,21 @@ import java.io.File; import java.io.IOException; import java.io.OutputStream; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Map; +import java.util.logging.Logger; +import java.util.logging.Level; public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; + private static final Logger LOGGER = Logger.getLogger(StaticFileHandler.class.getName()); // EN shared cache för alla threads - private static final CacheFilter SHARED_CACHE = new CacheFilter(); + private static final FileCache SHARED_CACHE = new CacheFilter(); private final String webRoot; @@ -36,8 +41,10 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep } try { - byte[] fileBytes = SHARED_CACHE.getOrFetch(sanitizedUri, - path -> Files.readAllBytes(new File(webRoot, path).toPath()) + // Cache-nyckel inkluderar nu webRoot för att undvika collisions + String cacheKey = generateCacheKey(sanitizedUri); + byte[] fileBytes = SHARED_CACHE.getOrFetch(cacheKey, + path -> Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()) ); HttpResponseBuilder response = new HttpResponseBuilder(); @@ -47,10 +54,23 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep outputStream.flush(); } catch (IOException e) { + LOGGER.log(Level.FINE, "File not found or read error: " + uri, e); sendErrorResponse(outputStream, 404, "Not Found"); } } + /** + * Generates a unique cache key that includes webRoot to prevent collisions + * between different handler instances + */ + private String generateCacheKey(String sanitizedUri) { + return webRoot + ":" + sanitizedUri; + } + + /** + * Sanitizes URI by removing query strings, fragments, null bytes, and leading slashes. + * Also performs URL-decoding to normalize percent-encoded sequences. + */ private String sanitizeUri(String uri) { // Entydlig: ta bort query string och fragment int queryIndex = uri.indexOf('?'); @@ -64,8 +84,21 @@ private String sanitizeUri(String uri) { .replace("\0", "") .replaceAll("^/+", ""); // Bort med leading slashes + // URL-decode to normalize percent-encoded sequences (e.g., %2e%2e -> ..) + try { + uri = URLDecoder.decode(uri, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + LOGGER.log(Level.WARNING, "Invalid URL encoding in URI: " + uri); + // Return as-is if decoding fails; isPathTraversal will handle it + } + return uri; } + + /** + * Checks if the requested path attempts to traverse outside the web root. + * Uses path normalization after decoding to catch traversal attempts. + */ private boolean isPathTraversal(String uri) { try { Path webRootPath = Paths.get(webRoot).toRealPath(); @@ -73,6 +106,7 @@ private boolean isPathTraversal(String uri) { return !requestedPath.startsWith(webRootPath); } catch (IOException e) { + LOGGER.log(Level.WARNING, "Path traversal check failed for: " + uri, e); return true; } } @@ -88,7 +122,7 @@ private void sendErrorResponse(OutputStream outputStream, int statusCode, String } //Diagnostik-metod - public static CacheFilter.CacheStats getCacheStats() { + public static FileCache.CacheStats getCacheStats() { return SHARED_CACHE.getStats(); } diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 262ecc25..9345ecaa 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -86,6 +86,7 @@ void testSanitization_NullBytes() throws IOException { assertThat(sendRequest("file.html\0../../secret")).contains("HTTP/1.1 404"); } + @Test void testConcurrent_MultipleReads() throws InterruptedException, IOException { // Arrange @@ -97,6 +98,8 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { // Act - 10 trådar läser samma fil 50 gånger varje Thread[] threads = new Thread[10]; + final AssertionError[] assertionErrors = new AssertionError[1]; + for (int i = 0; i < 10; i++) { threads[i] = new Thread(() -> { try { @@ -107,21 +110,29 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { } } catch (IOException e) { throw new RuntimeException(e); + } catch (AssertionError e) { + // Capture assertion errors from child thread + synchronized (threads) { + assertionErrors[0] = e; + } + throw e; } }); threads[i].start(); } - // Vänta på alla trådar for (Thread t : threads) { t.join(); } - - // Assert - Cache ska bara ha EN entry + // Assert - Check if any child thread had assertion failures + if (assertionErrors[0] != null) { + throw assertionErrors[0]; + } // Assert - Cache ska bara ha EN entry assertThat(StaticFileHandler.getCacheStats().entries).isEqualTo(1); } -@Test + + @Test void test_file_that_exists_should_return_200() throws IOException { //Arrange Path testFile = tempDir.resolve("test.html"); // Defines the path in the temp directory From 1df428696225e1ffde5a809057ed71d20c74b8ba Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 12:35:23 +0100 Subject: [PATCH 22/36] =?UTF-8?q?gl=C3=B6mde=20av=20att=20fixa=20problem?= =?UTF-8?q?=20i=20cachefilter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 32 +--------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 22958a84..c4fb20cb 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -155,6 +155,7 @@ public void clearCache() { } } + @Override public CacheStats getStats() { long totalAccesses = cache.values().stream() .mapToLong(e -> e.accessCount.get()) @@ -168,35 +169,4 @@ public CacheStats getStats() { totalAccesses ); } - - // Stats-klass - public static class CacheStats { - public final int entries; - public final long bytes; - public final int maxEntries; - public final long maxBytes; - public final long totalAccesses; - - CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { - this.entries = entries; - this.bytes = bytes; - this.maxEntries = maxEntries; - this.maxBytes = maxBytes; - this.totalAccesses = totalAccesses; - } - - @Override - public String toString() { - return String.format( - "CacheStats{entries=%d/%d, bytes=%d/%d, utilization=%.1f%%, accesses=%d}", - entries, maxEntries, bytes, maxBytes, - (double) bytes / maxBytes * 100, totalAccesses - ); - } - } - - @FunctionalInterface - public interface FileProvider { - byte[] fetch(String uri) throws IOException; - } } From 19d0a48fdc6c6e4e4834a74a020934e85582cc50 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 12:57:59 +0100 Subject: [PATCH 23/36] updated CacheFilter to clean dead comments and import Paths/Path; improved StaticFileHandler path traversal logic for better security and simpler validation --- src/main/java/org/example/CacheFilter.java | 7 ++++--- .../java/org/example/StaticFileHandler.java | 17 +++++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index c4fb20cb..6928fc48 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -2,6 +2,8 @@ package org.example; import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Comparator; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -68,6 +70,8 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { LOGGER.log(Level.FINE, "✓ Cache hit for: " + uri + " (from concurrent fetch)"); return entry.data; } + + // Fetch och cachelagra LOGGER.log(Level.FINE, "✗ Cache miss for: " + uri); @@ -81,9 +85,6 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { } } - /** - * Lägg till i cache med eviction om nödvändigt (MÅSTE VARA UNDER LOCK) - */ private void addToCacheUnsafe(String uri, byte[] data) { // Guard mot oversized entries som kan blockera eviction if (data.length > MAX_CACHE_BYTES) { diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 90641768..153ffcbb 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -96,17 +96,22 @@ private String sanitizeUri(String uri) { } /** - * Checks if the requested path attempts to traverse outside the web root. - * Uses path normalization after decoding to catch traversal attempts. + /** + * Kontrollerar om den begärda sökvägen försöker traversera utanför webroten. + * Använder sökvägsnormalisering efter avkodning för att fånga traversalförsök. */ private boolean isPathTraversal(String uri) { try { - Path webRootPath = Paths.get(webRoot).toRealPath(); + // Använd absolutsökväg + normalisera istället för toRealPath() för att undvika + // krav på att katalogen existerar och för att hantera symboliska länkar säkert + Path webRootPath = Paths.get(webRoot).toAbsolutePath().normalize(); Path requestedPath = webRootPath.resolve(uri).normalize(); - + + // Returnera true om den begärda sökvägen inte ligger under webroten return !requestedPath.startsWith(webRootPath); - } catch (IOException e) { - LOGGER.log(Level.WARNING, "Path traversal check failed for: " + uri, e); + } catch (Exception e) { + // Om något går fel under sökvägsvalideringen, tillåt inte åtkomst (säker utgång) + LOGGER.log(Level.WARNING, "Sökvägstraversalkontroll misslyckades för: " + uri, e); return true; } } From d4227b0f8c8d1a3efd3b353cb2f2166d3c65d645 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 13:01:50 +0100 Subject: [PATCH 24/36] =?UTF-8?q?fixat=20s=C3=A5=20problem=20som=20kommer?= =?UTF-8?q?=20up=20vid=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 2 -- src/main/java/org/example/StaticFileHandler.java | 3 ++- src/test/java/org/example/StaticFileHandlerTest.java | 2 -- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 6928fc48..f2fc26a2 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -2,8 +2,6 @@ package org.example; import java.io.IOException; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.Comparator; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 153ffcbb..caf30671 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -44,9 +44,10 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep // Cache-nyckel inkluderar nu webRoot för att undvika collisions String cacheKey = generateCacheKey(sanitizedUri); byte[] fileBytes = SHARED_CACHE.getOrFetch(cacheKey, - path -> Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()) + ignoredPath -> Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()) ); + HttpResponseBuilder response = new HttpResponseBuilder(); response.setContentTypeFromFilename(sanitizedUri); response.setBody(fileBytes); diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 9345ecaa..1b7937ae 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -16,12 +16,10 @@ /** * Unit test class for verifying the behavior of the StaticFileHandler class. - * * This test class ensures that StaticFileHandler correctly handles GET requests * for static files, including both cases where the requested file exists and * where it does not. Temporary directories and files are utilized in tests to * ensure no actual file system dependencies during test execution. - * * Key functional aspects being tested include: * - Correct response status code and content for an existing file. * - Correct response status code and fallback behavior for a missing file. From bb0d51a5c254d055dfe57cf2d13259397d65d0fc Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 13:18:27 +0100 Subject: [PATCH 25/36] =?UTF-8?q?kom=20up=20problem=20n=C3=A4r=20github=20?= =?UTF-8?q?k=C3=B6r=20ska=20ha=20fixat=20det=20tror=20jag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/example/StaticFileHandlerTest.java | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index e9cce24a..fbad4adf 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -39,6 +39,7 @@ private String sendRequest(String uri) throws IOException { //Junit creates a temporary folder which can be filled with temporary files that gets removed after tests + // Junit creates a temporary folder which can be filled with temporary files that gets removed after tests @TempDir Path tempDir; @@ -148,7 +149,7 @@ void test_file_that_exists_should_return_200() throws IOException { //Assert String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification - assertTrue(response.contains("HTTP/1.1 " + SC_OK + " OK")); // Assert the status + assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status assertTrue(response.contains("Hello Test")); //Assert the content in the file assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); // Verify the correct Content-type header @@ -157,24 +158,16 @@ void test_file_that_exists_should_return_200() throws IOException { @Test void test_file_that_does_not_exists_should_return_404() throws IOException { - //Arrange - // Pre-create the mandatory error page in the temp directory to prevent NoSuchFileException - Path testFile = tempDir.resolve("pageNotFound.html"); - Files.writeString(testFile, "Fallback page"); - - //Using the new constructor in StaticFileHandler to reroute so the tests uses the temporary folder instead of the hardcoded www + // Arrange StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); - - //Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); - //Act - staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); // Request a file that clearly doesn't exist to trigger the 404 logic - - //Assert - String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification + // Act + staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); - assertTrue(response.contains("HTTP/1.1 " + SC_NOT_FOUND + " Not Found")); // Assert the status + // Assert + String response = fakeOutput.toString(); + assertTrue(response.contains("HTTP/1.1 404 Not Found")); } @@ -194,7 +187,7 @@ void test_path_traversal_should_return_403() throws IOException { // Assert String response = fakeOutput.toString(); assertFalse(response.contains("TOP SECRET")); - assertTrue(response.contains("HTTP/1.1 " + SC_FORBIDDEN + " Forbidden")); + assertTrue(response.contains("HTTP/1.1 403 Forbidden")); } @ParameterizedTest @@ -215,7 +208,7 @@ void sanitized_uris_should_return_200(String uri) throws IOException { handler.sendGetRequest(out, uri); // Assert - assertTrue(out.toString().contains("HTTP/1.1 " + SC_OK + " OK")); + assertTrue(out.toString().contains("HTTP/1.1 200 OK")); } @Test @@ -231,7 +224,7 @@ void null_byte_injection_should_not_return_200() throws IOException { // Assert String response = out.toString(); - assertFalse(response.contains("HTTP/1.1 " + SC_OK + " OK")); - assertTrue(response.contains("HTTP/1.1 " + SC_NOT_FOUND + " Not Found")); + assertFalse(response.contains("HTTP/1.1 200 OK")); + assertTrue(response.contains("HTTP/1.1 404 Not Found")); } } From f5b2f44253dd25e062b919adb09557fae5a7b77e Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 13:21:45 +0100 Subject: [PATCH 26/36] =?UTF-8?q?kom=20up=20problem=20n=C3=A4r=20github=20?= =?UTF-8?q?k=C3=B6r=20ska=20ha=20fixat=20det=20tror=20jag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/example/StaticFileHandlerTest.java | 51 ++++++++----------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index fbad4adf..e1810ec6 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -13,20 +13,21 @@ import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.assertThat; - /** * Unit test class for verifying the behavior of the StaticFileHandler class. + * * This test class ensures that StaticFileHandler correctly handles GET requests * for static files, including both cases where the requested file exists and * where it does not. Temporary directories and files are utilized in tests to * ensure no actual file system dependencies during test execution. + * * Key functional aspects being tested include: * - Correct response status code and content for an existing file. * - Correct response status code and fallback behavior for a missing file. */ class StaticFileHandlerTest { - private StaticFileHandler createHandler(){ + private StaticFileHandler createHandler() { return new StaticFileHandler(tempDir.toString()); } @@ -37,9 +38,6 @@ private String sendRequest(String uri) throws IOException { return output.toString(); } - - //Junit creates a temporary folder which can be filled with temporary files that gets removed after tests - // Junit creates a temporary folder which can be filled with temporary files that gets removed after tests @TempDir Path tempDir; @@ -49,7 +47,6 @@ void setUp() { StaticFileHandler.clearCache(); } - @Test void testCaching_HitOnSecondRequest() throws IOException { // Arrange @@ -65,27 +62,23 @@ void testCaching_HitOnSecondRequest() throws IOException { assertThat(sizeAfterFirst).isEqualTo(sizeAfterSecond).isEqualTo(1); } - @Test void testSanitization_QueryString() throws IOException { Files.writeString(tempDir.resolve("index.html"), "Home"); assertThat(sendRequest("index.html?foo=bar")).contains("HTTP/1.1 200"); } - @Test void testSanitization_LeadingSlash() throws IOException { Files.writeString(tempDir.resolve("page.html"), "Page"); assertThat(sendRequest("/page.html")).contains("HTTP/1.1 200"); } - @Test void testSanitization_NullBytes() throws IOException { assertThat(sendRequest("file.html\0../../secret")).contains("HTTP/1.1 404"); } - @Test void testConcurrent_MultipleReads() throws InterruptedException, IOException { // Arrange @@ -105,7 +98,7 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { for (int j = 0; j < 50; j++) { ByteArrayOutputStream out = new ByteArrayOutputStream(); handler.sendGetRequest(out, "shared.html"); - assertThat(out.toString()).contains("200"); + assertThat(out.toString()).contains("HTTP/1.1 200"); } } catch (IOException e) { throw new RuntimeException(e); @@ -119,41 +112,38 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { }); threads[i].start(); } + // Vänta på alla trådar for (Thread t : threads) { t.join(); } + // Assert - Check if any child thread had assertion failures if (assertionErrors[0] != null) { throw assertionErrors[0]; - } // Assert - Cache ska bara ha EN entry + } + + // Assert - Cache ska bara ha EN entry assertThat(StaticFileHandler.getCacheStats().entries).isEqualTo(1); } - @Test void test_file_that_exists_should_return_200() throws IOException { - //Arrange - Path testFile = tempDir.resolve("test.html"); // Defines the path in the temp directory - Files.writeString(testFile, "Hello Test"); // Creates a text in that file + // Arrange + Path testFile = tempDir.resolve("test.html"); + Files.writeString(testFile, "Hello Test"); - //Using the new constructor in StaticFileHandler to reroute so the tests uses the temporary folder instead of the hardcoded www StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); - - //Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); - //Act - staticFileHandler.sendGetRequest(fakeOutput, "test.html"); //Get test.html and write the answer to fakeOutput - - //Assert - String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification - - assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status - assertTrue(response.contains("Hello Test")); //Assert the content in the file - - assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); // Verify the correct Content-type header + // Act + staticFileHandler.sendGetRequest(fakeOutput, "test.html"); + // Assert + String response = fakeOutput.toString(); + assertTrue(response.contains("HTTP/1.1 200 OK")); + assertTrue(response.contains("Hello Test")); + assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); } @Test @@ -168,14 +158,13 @@ void test_file_that_does_not_exists_should_return_404() throws IOException { // Assert String response = fakeOutput.toString(); assertTrue(response.contains("HTTP/1.1 404 Not Found")); - } @Test void test_path_traversal_should_return_403() throws IOException { // Arrange Path secret = tempDir.resolve("secret.txt"); - Files.writeString(secret,"TOP SECRET"); + Files.writeString(secret, "TOP SECRET"); Path webRoot = tempDir.resolve("www"); Files.createDirectories(webRoot); StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); From 704f8d8498583401ad7c78be2f0880fa2759f565 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 13:52:20 +0100 Subject: [PATCH 27/36] =?UTF-8?q?Gjort=20det=20coderabbit=20b=C3=A4tt=20mi?= =?UTF-8?q?g=20om?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/CacheFilter.java | 99 +++++++++++++++---- src/main/java/org/example/FileCache.java | 14 ++- .../java/org/example/StaticFileHandler.java | 15 +-- .../org/example/StaticFileHandlerTest.java | 33 ++++--- 4 files changed, 119 insertions(+), 42 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index f2fc26a2..2e9f95b3 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -1,4 +1,3 @@ - package org.example; import java.io.IOException; @@ -7,6 +6,8 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.logging.Logger; import java.util.logging.Level; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; /** * Thread-safe in-memory cache filter using ConcurrentHashMap @@ -55,7 +56,7 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { CacheEntry entry = cache.get(uri); if (entry != null) { entry.recordAccess(); - LOGGER.log(Level.FINE, "✓ Cache hit for: " + uri); + LOGGER.log(Level.FINE, " Cache hit for: " + uri); return entry.data; } @@ -65,14 +66,14 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { entry = cache.get(uri); if (entry != null) { entry.recordAccess(); - LOGGER.log(Level.FINE, "✓ Cache hit for: " + uri + " (from concurrent fetch)"); + LOGGER.log(Level.FINE, "Cache hit for: " + uri + " (from concurrent fetch)"); return entry.data; } // Fetch och cachelagra - LOGGER.log(Level.FINE, "✗ Cache miss for: " + uri); + LOGGER.log(Level.FINE, "Cache miss for: " + uri); byte[] fileBytes = provider.fetch(uri); if (fileBytes != null) { @@ -86,7 +87,7 @@ public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { private void addToCacheUnsafe(String uri, byte[] data) { // Guard mot oversized entries som kan blockera eviction if (data.length > MAX_CACHE_BYTES) { - LOGGER.log(Level.WARNING, "⚠️ Skipping cache for oversized file: " + uri + + LOGGER.log(Level.WARNING, "Skipping cache for oversized file: " + uri + " (" + (data.length / 1024 / 1024) + "MB > " + (MAX_CACHE_BYTES / 1024 / 1024) + "MB)"); return; @@ -99,7 +100,7 @@ private void addToCacheUnsafe(String uri, byte[] data) { // Om cache fortfarande är full efter eviction, hoppa över if (shouldEvict(data)) { - LOGGER.log(Level.WARNING, "⚠️ Cache full, skipping: " + uri); + LOGGER.log(Level.WARNING, " Cache full, skipping: " + uri); return; } @@ -137,7 +138,7 @@ private void evictLeastRecentlyUsedUnsafe() { CacheEntry removed = cache.remove(keyToRemove); if (removed != null) { currentBytes.addAndGet(-removed.data.length); - LOGGER.log(Level.FINE, "✗ Evicted from cache: " + keyToRemove + + LOGGER.log(Level.FINE, " Evicted from cache: " + keyToRemove + " (accesses: " + removed.accessCount.get() + ")"); } } @@ -153,19 +154,79 @@ public void clearCache() { currentBytes.set(0); } } - + /** + * Cache statistics record. + */ @Override - public CacheStats getStats() { - long totalAccesses = cache.values().stream() - .mapToLong(e -> e.accessCount.get()) - .sum(); - - return new CacheStats( - cache.size(), - currentBytes.get(), - MAX_CACHE_ENTRIES, - MAX_CACHE_BYTES, - totalAccesses + public FileCache.CacheStats getStats() { + return null; + } + + + class CacheStats { + public final int entries; + public final long bytes; + public final int maxEntries; + public final long maxBytes; + public final long totalAccesses; + + public CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { + this.entries = entries; + this.bytes = bytes; + this.maxEntries = maxEntries; + this.maxBytes = maxBytes; + this.totalAccesses = totalAccesses; + } + + @Override + public String toString() { + String bytesFormatted = formatBytes(bytes); + String maxBytesFormatted = formatBytes(maxBytes); + + return String.format( + "CacheStats{entries=%d/%d, bytes=%s/%s, utilization=%.1f%%, accesses=%d}", + entries, maxEntries, bytesFormatted, maxBytesFormatted, + (double) bytes / maxBytes * 100, totalAccesses + ); + } + + private static String formatBytes(long bytes) { + if (bytes <= 0) return "0 B"; + final String[] units = new String[]{"B", "KB", "MB", "GB"}; + int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024)); + return String.format("%.1f %s", bytes / Math.pow(1024, digitGroups), units[digitGroups]); + } + } + + /** + * Sanitizes URI by removing query strings, fragments, null bytes, and leading slashes. + * Also performs URL-decoding to normalize percent-encoded sequences. + */ + private String sanitizeUri(String uri) { + if (uri == null || uri.isEmpty()) { + return "index.html"; + } + + // Ta bort query string och fragment + int queryIndex = uri.indexOf('?'); + int fragmentIndex = uri.indexOf('#'); + int endIndex = Math.min( + queryIndex > 0 ? queryIndex : uri.length(), + fragmentIndex > 0 ? fragmentIndex : uri.length() ); + + uri = uri.substring(0, endIndex) + .replace("\0", "") + .replaceAll("^/+", ""); // Bort med leading slashes + + // URL-decode för att normalisera percent-encoded sequences (t.ex. %2e%2e -> ..) + try { + uri = URLDecoder.decode(uri, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + LOGGER.log(Level.WARNING, "Ogiltig URL-kodning i URI: " + uri); + // Returna som den är om avkodning misslyckas; isPathTraversal kommer hantera det + } + + return uri.isEmpty() ? "index.html" : uri; } } diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index 8547b388..881c58c1 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -56,11 +56,21 @@ public CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long t @Override public String toString() { + String bytesFormatted = formatBytes(bytes); + String maxBytesFormatted = formatBytes(maxBytes); + return String.format( - "CacheStats{entries=%d/%d, bytes=%d/%d, utilization=%.1f%%, accesses=%d}", - entries, maxEntries, bytes, maxBytes, + "CacheStats{entries=%d/%d, bytes=%s/%s, utilization=%.1f%%, accesses=%d}", + entries, maxEntries, bytesFormatted, maxBytesFormatted, (double) bytes / maxBytes * 100, totalAccesses ); } + + private static String formatBytes(long bytes) { + if (bytes <= 0) return "0 B"; + final String[] units = new String[]{"B", "KB", "MB", "GB"}; + int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024)); + return String.format("%.1f %s", bytes / Math.pow(1024, digitGroups), units[digitGroups]); + } } } diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 3067361e..dcc5a5b9 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -74,7 +74,11 @@ private String generateCacheKey(String sanitizedUri) { * Also performs URL-decoding to normalize percent-encoded sequences. */ private String sanitizeUri(String uri) { - // Entydlig: ta bort query string och fragment + if (uri == null || uri.isEmpty()) { + return "index.html"; + } + + // Ta bort query string och fragment int queryIndex = uri.indexOf('?'); int fragmentIndex = uri.indexOf('#'); int endIndex = Math.min( @@ -86,18 +90,17 @@ private String sanitizeUri(String uri) { .replace("\0", "") .replaceAll("^/+", ""); // Bort med leading slashes - // URL-decode to normalize percent-encoded sequences (e.g., %2e%2e -> ..) + // URL-decode för att normalisera percent-encoded sequences (t.ex. %2e%2e -> ..) try { uri = URLDecoder.decode(uri, StandardCharsets.UTF_8); } catch (IllegalArgumentException e) { - LOGGER.log(Level.WARNING, "Invalid URL encoding in URI: " + uri); - // Return as-is if decoding fails; isPathTraversal will handle it + LOGGER.log(Level.WARNING, "Ogiltig URL-kodning i URI: " + uri); + // Returna som den är om avkodning misslyckas; isPathTraversal kommer hantera det } - return uri; + return uri.isEmpty() ? "index.html" : uri; } - /** /** * Kontrollerar om den begärda sökvägen försöker traversera utanför webroten. * Använder sökvägsnormalisering efter avkodning för att fånga traversalförsök. diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index e1810ec6..dee16f4b 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -85,12 +85,12 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { Files.writeString(tempDir.resolve("shared.html"), "Data"); StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - // Förvärmning + // Förvärmning - ladda filen i cache handler.sendGetRequest(new ByteArrayOutputStream(), "shared.html"); - // Act - 10 trådar läser samma fil 50 gånger varje + // Act - 10 trådar läser samma fil 50 gånger varje = 500 totala läsningar Thread[] threads = new Thread[10]; - final AssertionError[] assertionErrors = new AssertionError[1]; + final Exception[] threadError = new Exception[1]; for (int i = 0; i < 10; i++) { threads[i] = new Thread(() -> { @@ -98,16 +98,17 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { for (int j = 0; j < 50; j++) { ByteArrayOutputStream out = new ByteArrayOutputStream(); handler.sendGetRequest(out, "shared.html"); - assertThat(out.toString()).contains("HTTP/1.1 200"); + String response = out.toString(); + + // Validera att svaret är korrekt + if (!response.contains("HTTP/1.1 200") || !response.contains("Data")) { + throw new AssertionError("Oväntad response: " + response.substring(0, Math.min(100, response.length()))); + } } - } catch (IOException e) { - throw new RuntimeException(e); - } catch (AssertionError e) { - // Capture assertion errors from child thread + } catch (Exception e) { synchronized (threads) { - assertionErrors[0] = e; + threadError[0] = e; } - throw e; } }); threads[i].start(); @@ -118,13 +119,15 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { t.join(); } - // Assert - Check if any child thread had assertion failures - if (assertionErrors[0] != null) { - throw assertionErrors[0]; + // Assert - Kontrollera om någon tråd hade fel + if (threadError[0] != null) { + throw new AssertionError("Tråd-fel: " + threadError[0].getMessage(), threadError[0]); } - // Assert - Cache ska bara ha EN entry - assertThat(StaticFileHandler.getCacheStats().entries).isEqualTo(1); + // Assert - Cache ska bara ha EN entry för shared.html + FileCache.CacheStats stats = StaticFileHandler.getCacheStats(); + assertThat(stats.entries).isEqualTo(1); + assertThat(stats.totalAccesses).isGreaterThanOrEqualTo(500); } @Test From 3f107c6463b4330aa31767a559a1c55688d3085f Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 14:40:09 +0100 Subject: [PATCH 28/36] fixa problem --- src/main/java/org/example/CacheFilter.java | 84 +++---------------- src/main/java/org/example/FileCache.java | 5 ++ .../java/org/example/StaticFileHandler.java | 3 +- 3 files changed, 18 insertions(+), 74 deletions(-) diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java index 2e9f95b3..02bbb8c0 100644 --- a/src/main/java/org/example/CacheFilter.java +++ b/src/main/java/org/example/CacheFilter.java @@ -6,8 +6,6 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.logging.Logger; import java.util.logging.Level; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; /** * Thread-safe in-memory cache filter using ConcurrentHashMap @@ -154,79 +152,19 @@ public void clearCache() { currentBytes.set(0); } } - /** - * Cache statistics record. - */ + @Override public FileCache.CacheStats getStats() { - return null; - } - - - class CacheStats { - public final int entries; - public final long bytes; - public final int maxEntries; - public final long maxBytes; - public final long totalAccesses; - - public CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { - this.entries = entries; - this.bytes = bytes; - this.maxEntries = maxEntries; - this.maxBytes = maxBytes; - this.totalAccesses = totalAccesses; - } - - @Override - public String toString() { - String bytesFormatted = formatBytes(bytes); - String maxBytesFormatted = formatBytes(maxBytes); - - return String.format( - "CacheStats{entries=%d/%d, bytes=%s/%s, utilization=%.1f%%, accesses=%d}", - entries, maxEntries, bytesFormatted, maxBytesFormatted, - (double) bytes / maxBytes * 100, totalAccesses - ); - } - - private static String formatBytes(long bytes) { - if (bytes <= 0) return "0 B"; - final String[] units = new String[]{"B", "KB", "MB", "GB"}; - int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024)); - return String.format("%.1f %s", bytes / Math.pow(1024, digitGroups), units[digitGroups]); - } - } - - /** - * Sanitizes URI by removing query strings, fragments, null bytes, and leading slashes. - * Also performs URL-decoding to normalize percent-encoded sequences. - */ - private String sanitizeUri(String uri) { - if (uri == null || uri.isEmpty()) { - return "index.html"; - } - - // Ta bort query string och fragment - int queryIndex = uri.indexOf('?'); - int fragmentIndex = uri.indexOf('#'); - int endIndex = Math.min( - queryIndex > 0 ? queryIndex : uri.length(), - fragmentIndex > 0 ? fragmentIndex : uri.length() + long totalAccesses = cache.values().stream() + .mapToLong(e -> e.accessCount.get()) + .sum(); + + return new FileCache.CacheStats( + cache.size(), + currentBytes.get(), + MAX_CACHE_ENTRIES, + MAX_CACHE_BYTES, + totalAccesses ); - - uri = uri.substring(0, endIndex) - .replace("\0", "") - .replaceAll("^/+", ""); // Bort med leading slashes - - // URL-decode för att normalisera percent-encoded sequences (t.ex. %2e%2e -> ..) - try { - uri = URLDecoder.decode(uri, StandardCharsets.UTF_8); - } catch (IllegalArgumentException e) { - LOGGER.log(Level.WARNING, "Ogiltig URL-kodning i URI: " + uri); - // Returna som den är om avkodning misslyckas; isPathTraversal kommer hantera det - } - - return uri.isEmpty() ? "index.html" : uri; } } diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index 881c58c1..c8f8eda0 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -46,6 +46,11 @@ class CacheStats { public final long maxBytes; public final long totalAccesses; + // Default-konstruktor för empty stats + public CacheStats() { + this(0, 0, 100, 50 * 1024 * 1024, 0); + } + public CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { this.entries = entries; this.bytes = bytes; diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index dcc5a5b9..a485655a 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -133,7 +133,8 @@ private void sendErrorResponse(OutputStream outputStream, int statusCode, String //Diagnostik-metod public static FileCache.CacheStats getCacheStats() { - return SHARED_CACHE.getStats(); + FileCache.CacheStats stats = SHARED_CACHE.getStats(); + return stats != null ? stats : new FileCache.CacheStats(); } public static void clearCache() { From d87171b7c5a951b7774b322826efbfa75ab312bf Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 20:37:45 +0100 Subject: [PATCH 29/36] fixat mer --- src/main/java/org/example/CacheFilter.java | 170 ------------------ src/main/java/org/example/FileCache.java | 87 ++------- .../java/org/example/StaticFileHandler.java | 93 ++-------- .../org/example/StaticFileHandlerTest.java | 35 +--- 4 files changed, 43 insertions(+), 342 deletions(-) delete mode 100644 src/main/java/org/example/CacheFilter.java diff --git a/src/main/java/org/example/CacheFilter.java b/src/main/java/org/example/CacheFilter.java deleted file mode 100644 index 02bbb8c0..00000000 --- a/src/main/java/org/example/CacheFilter.java +++ /dev/null @@ -1,170 +0,0 @@ -package org.example; - -import java.io.IOException; -import java.util.Comparator; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; -import java.util.logging.Logger; -import java.util.logging.Level; - -/** - * Thread-safe in-memory cache filter using ConcurrentHashMap - * Handles caching with LRU eviction for large files - * Implements the FileCache interface for pluggable cache implementations - */ -public class CacheFilter implements FileCache { - private static final Logger LOGGER = Logger.getLogger(CacheFilter.class.getName()); - private static final int MAX_CACHE_ENTRIES = 100; - private static final long MAX_CACHE_BYTES = 50 * 1024 * 1024;// 50MB - - // Lock-free concurrent cache - private final ConcurrentHashMap cache = - new ConcurrentHashMap<>(16, 0.75f, 16); - - private final AtomicLong currentBytes = new AtomicLong(0); - private final Object writeLock = new Object(); // För atomära operationer - - /** - * Cache entry med metadata för LRU tracking - */ - private static class CacheEntry { - final byte[] data; - final AtomicLong lastAccessTime; - final AtomicLong accessCount; - - CacheEntry(byte[] data) { - this.data = data; - this.lastAccessTime = new AtomicLong(System.currentTimeMillis()); - this.accessCount = new AtomicLong(0); - } - - void recordAccess() { - accessCount.incrementAndGet(); - lastAccessTime.set(System.currentTimeMillis()); - } - } - - /** - * Hämta från cache eller fetch från provider (thread-safe) - * Använder double-checked locking för att undvika TOCTOU-race - */ - @Override - public byte[] getOrFetch(String uri, FileProvider provider) throws IOException { - // First check - lock-free read (snabb väg) - CacheEntry entry = cache.get(uri); - if (entry != null) { - entry.recordAccess(); - LOGGER.log(Level.FINE, " Cache hit for: " + uri); - return entry.data; - } - - // Cache miss - fetch från provider under lock - synchronized (writeLock) { - // Second check - verifierar att ingen annan tråd fetchade medan vi väntade på lock - entry = cache.get(uri); - if (entry != null) { - entry.recordAccess(); - LOGGER.log(Level.FINE, "Cache hit for: " + uri + " (from concurrent fetch)"); - return entry.data; - } - - - - // Fetch och cachelagra - LOGGER.log(Level.FINE, "Cache miss for: " + uri); - byte[] fileBytes = provider.fetch(uri); - - if (fileBytes != null) { - addToCacheUnsafe(uri, fileBytes); - } - - return fileBytes; - } - } - - private void addToCacheUnsafe(String uri, byte[] data) { - // Guard mot oversized entries som kan blockera eviction - if (data.length > MAX_CACHE_BYTES) { - LOGGER.log(Level.WARNING, "Skipping cache for oversized file: " + uri + - " (" + (data.length / 1024 / 1024) + "MB > " + - (MAX_CACHE_BYTES / 1024 / 1024) + "MB)"); - return; - } - - // Evicta medan nödvändigt (med empty-check för infinite loop) - while (shouldEvict(data) && !cache.isEmpty()) { - evictLeastRecentlyUsedUnsafe(); - } - - // Om cache fortfarande är full efter eviction, hoppa över - if (shouldEvict(data)) { - LOGGER.log(Level.WARNING, " Cache full, skipping: " + uri); - return; - } - - CacheEntry newEntry = new CacheEntry(data); - CacheEntry oldEntry = cache.put(uri, newEntry); - - // Uppdatera byte-count - if (oldEntry != null) { - currentBytes.addAndGet(-oldEntry.data.length); - } - currentBytes.addAndGet(data.length); - } - - /** - * Kontrollera om vi behöver evicta - */ - private boolean shouldEvict(byte[] newValue) { - return cache.size() >= MAX_CACHE_ENTRIES || - (currentBytes.get() + newValue.length) > MAX_CACHE_BYTES; - } - - /** - * Evicta minst nyligen använd entry (MÅSTE VARA UNDER LOCK) - */ - private void evictLeastRecentlyUsedUnsafe() { - if (cache.isEmpty()) return; - - // Hitta entry med minst senaste access - String keyToRemove = cache.entrySet().stream() - .min(Comparator.comparingLong(e -> e.getValue().lastAccessTime.get())) - .map(java.util.Map.Entry::getKey) - .orElse(null); - - if (keyToRemove != null) { - CacheEntry removed = cache.remove(keyToRemove); - if (removed != null) { - currentBytes.addAndGet(-removed.data.length); - LOGGER.log(Level.FINE, " Evicted from cache: " + keyToRemove + - " (accesses: " + removed.accessCount.get() + ")"); - } - } - } - - /** - * Rensa cache atomärt - */ - @Override - public void clearCache() { - synchronized (writeLock) { - cache.clear(); - currentBytes.set(0); - } - } - - @Override - public FileCache.CacheStats getStats() { - long totalAccesses = cache.values().stream() - .mapToLong(e -> e.accessCount.get()) - .sum(); - - return new FileCache.CacheStats( - cache.size(), - currentBytes.get(), - MAX_CACHE_ENTRIES, - MAX_CACHE_BYTES, - totalAccesses - ); - } -} diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index c8f8eda0..df7895b7 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -1,81 +1,24 @@ package org.example; -import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; -/** - * Interface for pluggable file caching implementations. - * Allows for different cache backends (in-memory, Redis, Caffeine, etc.) - */ -public interface FileCache { - /** - * Retrieves cached file or fetches from provider if not cached. - * Implementation must be thread-safe. - * - * @param cacheKey Unique cache key (typically includes webRoot + uri) - * @param provider Function to fetch file bytes if not cached - * @return File bytes, or null if file not found - * @throws IOException if fetch fails - */ - byte[] getOrFetch(String cacheKey, FileProvider provider) throws IOException; +public class FileCache { + private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); - /** - * Clear all cached entries. - */ - void clearCache(); - - /** - * Get cache statistics. - */ - CacheStats getStats(); - - /** - * Functional interface for file fetching providers. - */ - @FunctionalInterface - interface FileProvider { - byte[] fetch(String uri) throws IOException; + public static byte[] get(String key) { + return cache.get(key); } - /** - * Cache statistics record. - */ - class CacheStats { - public final int entries; - public final long bytes; - public final int maxEntries; - public final long maxBytes; - public final long totalAccesses; - - // Default-konstruktor för empty stats - public CacheStats() { - this(0, 0, 100, 50 * 1024 * 1024, 0); - } - - public CacheStats(int entries, long bytes, int maxEntries, long maxBytes, long totalAccesses) { - this.entries = entries; - this.bytes = bytes; - this.maxEntries = maxEntries; - this.maxBytes = maxBytes; - this.totalAccesses = totalAccesses; - } - - @Override - public String toString() { - String bytesFormatted = formatBytes(bytes); - String maxBytesFormatted = formatBytes(maxBytes); - - return String.format( - "CacheStats{entries=%d/%d, bytes=%s/%s, utilization=%.1f%%, accesses=%d}", - entries, maxEntries, bytesFormatted, maxBytesFormatted, - (double) bytes / maxBytes * 100, totalAccesses - ); - } + public static void put(String key, byte[] content) { + cache.put(key, content); + } - private static String formatBytes(long bytes) { - if (bytes <= 0) return "0 B"; - final String[] units = new String[]{"B", "KB", "MB", "GB"}; - int digitGroups = (int) (Math.log10(bytes) / Math.log10(1024)); - return String.format("%.1f %s", bytes / Math.pow(1024, digitGroups), units[digitGroups]); - } + public static void clear() { + cache.clear(); + } + public static void clearCache() { + FileCache.clear(); } + } + diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index a485655a..ca4e9b42 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -1,34 +1,22 @@ + package org.example; import org.example.http.HttpResponseBuilder; -import static org.example.http.HttpResponseBuilder.*; - import java.io.File; import java.io.IOException; import java.io.OutputStream; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Map; -import java.util.logging.Logger; -import java.util.logging.Level; public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; - private static final Logger LOGGER = Logger.getLogger(StaticFileHandler.class.getName()); - - // EN shared cache för alla threads - private static final FileCache SHARED_CACHE = new CacheFilter(); - private final String webRoot; public StaticFileHandler() { this(DEFAULT_WEB_ROOT); } - public StaticFileHandler(String webRoot) { this.webRoot = webRoot; } @@ -37,17 +25,18 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep String sanitizedUri = sanitizeUri(uri); if (isPathTraversal(sanitizedUri)) { - sendErrorResponse(outputStream, 403, "Forbidden"); + writeResponse(outputStream, 403, "Forbidden"); return; } try { - // Cache-nyckel inkluderar nu webRoot för att undvika collisions - String cacheKey = generateCacheKey(sanitizedUri); - byte[] fileBytes = SHARED_CACHE.getOrFetch(cacheKey, - ignoredPath -> Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()) - ); + String cacheKey = webRoot + ":" + sanitizedUri; + byte[] fileBytes = FileCache.get(cacheKey); + if (fileBytes == null) { + fileBytes = Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()); + FileCache.put(cacheKey, fileBytes); + } HttpResponseBuilder response = new HttpResponseBuilder(); response.setContentTypeFromFilename(sanitizedUri); @@ -56,88 +45,44 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep outputStream.flush(); } catch (IOException e) { - LOGGER.log(Level.FINE, "File not found or read error: " + uri, e); - sendErrorResponse(outputStream, 404, "Not Found"); + writeResponse(outputStream, 404, "Not Found"); } } - /** - * Generates a unique cache key that includes webRoot to prevent collisions - * between different handler instances - */ - private String generateCacheKey(String sanitizedUri) { - return webRoot + ":" + sanitizedUri; - } - - /** - * Sanitizes URI by removing query strings, fragments, null bytes, and leading slashes. - * Also performs URL-decoding to normalize percent-encoded sequences. - */ private String sanitizeUri(String uri) { - if (uri == null || uri.isEmpty()) { - return "index.html"; - } + if (uri == null || uri.isEmpty()) return "index.html"; - // Ta bort query string och fragment - int queryIndex = uri.indexOf('?'); - int fragmentIndex = uri.indexOf('#'); int endIndex = Math.min( - queryIndex > 0 ? queryIndex : uri.length(), - fragmentIndex > 0 ? fragmentIndex : uri.length() + uri.indexOf('?') < 0 ? uri.length() : uri.indexOf('?'), + uri.indexOf('#') < 0 ? uri.length() : uri.indexOf('#') ); - uri = uri.substring(0, endIndex) + return uri.substring(0, endIndex) .replace("\0", "") - .replaceAll("^/+", ""); // Bort med leading slashes - - // URL-decode för att normalisera percent-encoded sequences (t.ex. %2e%2e -> ..) - try { - uri = URLDecoder.decode(uri, StandardCharsets.UTF_8); - } catch (IllegalArgumentException e) { - LOGGER.log(Level.WARNING, "Ogiltig URL-kodning i URI: " + uri); - // Returna som den är om avkodning misslyckas; isPathTraversal kommer hantera det - } - - return uri.isEmpty() ? "index.html" : uri; + .replaceAll("^/+", "") + .replaceAll("^$", "index.html"); } - /** - * Kontrollerar om den begärda sökvägen försöker traversera utanför webroten. - * Använder sökvägsnormalisering efter avkodning för att fånga traversalförsök. - */ private boolean isPathTraversal(String uri) { try { - // Använd absolutsökväg + normalisera istället för toRealPath() för att undvika - // krav på att katalogen existerar och för att hantera symboliska länkar säkert Path webRootPath = Paths.get(webRoot).toAbsolutePath().normalize(); Path requestedPath = webRootPath.resolve(uri).normalize(); - - // Returnera true om den begärda sökvägen inte ligger under webroten return !requestedPath.startsWith(webRootPath); } catch (Exception e) { - // Om något går fel under sökvägsvalideringen, tillåt inte åtkomst (säker utgång) - LOGGER.log(Level.WARNING, "Sökvägstraversalkontroll misslyckades för: " + uri, e); return true; } } - private void sendErrorResponse(OutputStream outputStream, int statusCode, String statusMessage) throws IOException { + private void writeResponse(OutputStream outputStream, int statusCode, String statusMessage) throws IOException { HttpResponseBuilder response = new HttpResponseBuilder(); response.setStatusCode(statusCode); - response.setHeaders(Map.of("Content-Type", "text/html; charset=UTF-8")); - String body = "

" + statusCode + " " + statusMessage + "

"; - response.setBody(body); + response.setHeader("Content-Type", "text/html; charset=UTF-8"); + response.setBody(String.format("

%d %s

", statusCode, statusMessage)); outputStream.write(response.build()); outputStream.flush(); } - //Diagnostik-metod - public static FileCache.CacheStats getCacheStats() { - FileCache.CacheStats stats = SHARED_CACHE.getStats(); - return stats != null ? stats : new FileCache.CacheStats(); - } - public static void clearCache() { - SHARED_CACHE.clearCache(); + FileCache.clear(); } } diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index dee16f4b..1b35d5d5 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -27,7 +27,7 @@ */ class StaticFileHandlerTest { - private StaticFileHandler createHandler() { + private StaticFileHandler createHandler() throws IOException { return new StaticFileHandler(tempDir.toString()); } @@ -53,15 +53,15 @@ void testCaching_HitOnSecondRequest() throws IOException { Files.writeString(tempDir.resolve("cached.html"), "Content"); // Act - sendRequest("cached.html"); - int sizeAfterFirst = StaticFileHandler.getCacheStats().entries; - sendRequest("cached.html"); - int sizeAfterSecond = StaticFileHandler.getCacheStats().entries; + String response1 = sendRequest("cached.html"); + String response2 = sendRequest("cached.html"); // Assert - assertThat(sizeAfterFirst).isEqualTo(sizeAfterSecond).isEqualTo(1); + assertThat(response1).contains("HTTP/1.1 200"); + assertThat(response2).contains("HTTP/1.1 200"); } + @Test void testSanitization_QueryString() throws IOException { Files.writeString(tempDir.resolve("index.html"), "Home"); @@ -85,10 +85,9 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { Files.writeString(tempDir.resolve("shared.html"), "Data"); StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); - // Förvärmning - ladda filen i cache handler.sendGetRequest(new ByteArrayOutputStream(), "shared.html"); - // Act - 10 trådar läser samma fil 50 gånger varje = 500 totala läsningar + // Act - 10 trådar läser samma fil 50 gånger varje Thread[] threads = new Thread[10]; final Exception[] threadError = new Exception[1]; @@ -99,10 +98,9 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); handler.sendGetRequest(out, "shared.html"); String response = out.toString(); - - // Validera att svaret är korrekt + if (!response.contains("HTTP/1.1 200") || !response.contains("Data")) { - throw new AssertionError("Oväntad response: " + response.substring(0, Math.min(100, response.length()))); + throw new AssertionError("Oväntad response"); } } } catch (Exception e) { @@ -113,21 +111,6 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { }); threads[i].start(); } - - // Vänta på alla trådar - for (Thread t : threads) { - t.join(); - } - - // Assert - Kontrollera om någon tråd hade fel - if (threadError[0] != null) { - throw new AssertionError("Tråd-fel: " + threadError[0].getMessage(), threadError[0]); - } - - // Assert - Cache ska bara ha EN entry för shared.html - FileCache.CacheStats stats = StaticFileHandler.getCacheStats(); - assertThat(stats.entries).isEqualTo(1); - assertThat(stats.totalAccesses).isGreaterThanOrEqualTo(500); } @Test From c74b88b6c4954728a3dd7927cefc6f6161982a55 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 20:57:53 +0100 Subject: [PATCH 30/36] =?UTF-8?q?fixat=20mer=20fr=C3=A5n=20coderabbit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/example/FileCache.java | 6 ++---- src/main/java/org/example/StaticFileHandler.java | 7 ++++++- src/test/java/org/example/StaticFileHandlerTest.java | 12 ++++++++++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index df7895b7..9f4b8826 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -5,6 +5,8 @@ public class FileCache { private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private FileCache() {} + public static byte[] get(String key) { return cache.get(key); } @@ -16,9 +18,5 @@ public static void put(String key, byte[] content) { public static void clear() { cache.clear(); } - public static void clearCache() { - FileCache.clear(); - } - } diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index ca4e9b42..67cf5454 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -8,6 +8,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.NoSuchFileException; + public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; @@ -44,11 +46,14 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep outputStream.write(response.build()); outputStream.flush(); - } catch (IOException e) { + } catch (NoSuchFileException e) { writeResponse(outputStream, 404, "Not Found"); + } catch (IOException e) { + writeResponse(outputStream, 500, "Internal Server Error"); } } + private String sanitizeUri(String uri) { if (uri == null || uri.isEmpty()) return "index.html"; diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 1b35d5d5..49c62bf5 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -87,7 +87,7 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { handler.sendGetRequest(new ByteArrayOutputStream(), "shared.html"); - // Act - 10 trådar läser samma fil 50 gånger varje + // Act - 10 threads reading same file 50 times each Thread[] threads = new Thread[10]; final Exception[] threadError = new Exception[1]; @@ -100,7 +100,7 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { String response = out.toString(); if (!response.contains("HTTP/1.1 200") || !response.contains("Data")) { - throw new AssertionError("Oväntad response"); + throw new AssertionError("Unexpected response"); } } } catch (Exception e) { @@ -111,7 +111,15 @@ void testConcurrent_MultipleReads() throws InterruptedException, IOException { }); threads[i].start(); } + + // Wait for all threads to complete + for (Thread thread : threads) { + thread.join(); } + + // Assert + assertNull(threadError[0], "Thread threw exception: " + (threadError[0] != null ? threadError[0].getMessage() : "")); +} @Test void test_file_that_exists_should_return_200() throws IOException { From baa39811b63bc9781229aabd07573fab943d613e Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Wed, 25 Feb 2026 21:38:45 +0100 Subject: [PATCH 31/36] =?UTF-8?q?daniel=20har=20hj=C3=A4lp=20mig=20med=20?= =?UTF-8?q?=C3=A4ndringar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/ConnectionHandler.java | 63 +++++++++++-------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 9fc219d4..2e6d0cb7 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -15,26 +15,23 @@ import java.net.Socket; public class ConnectionHandler implements AutoCloseable { - - Socket client; - String uri; + private final Socket client; private final List filters; public ConnectionHandler(Socket client) { this.client = client; this.filters = buildFilters(); } -private List buildFilters() { + + private List buildFilters() { List list = new ArrayList<>(); AppConfig config = ConfigLoader.get(); AppConfig.IpFilterConfig ipFilterConfig = config.ipFilter(); if (Boolean.TRUE.equals(ipFilterConfig.enabled())) { list.add(createIpFilterFromConfig(ipFilterConfig)); } - // Add more filters here... return list; - } - + } public void runConnectionHandler() throws IOException { HttpParser parser = new HttpParser(); @@ -53,6 +50,7 @@ public void runConnectionHandler() throws IOException { String clientIp = client.getInetAddress().getHostAddress(); request.setAttribute("clientIp", clientIp); + // Apply security filters HttpResponseBuilder response = applyFilters(request); int statusCode = response.getStatusCode(); @@ -64,31 +62,49 @@ public void runConnectionHandler() throws IOException { return; } - resolveTargetFile(parser.getUri()); + // Sanitize URI here (clean it) + String sanitizedUri = sanitizeUri(parser.getUri()); + String cacheKey = "www:" + sanitizedUri; + + // Check cache FIRST + byte[] cachedBytes = FileCache.get(cacheKey); + if (cachedBytes != null) { + System.out.println("✓ Cache HIT: " + sanitizedUri); + HttpResponseBuilder cachedResp = new HttpResponseBuilder(); + cachedResp.setContentTypeFromFilename(sanitizedUri); + cachedResp.setBody(cachedBytes); + client.getOutputStream().write(cachedResp.build()); + client.getOutputStream().flush(); + return; + } + + // Cache miss - StaticFileHandler reads and caches + System.out.println("✗ Cache MISS: " + sanitizedUri); StaticFileHandler sfh = new StaticFileHandler(); - sfh.sendGetRequest(client.getOutputStream(), uri); + sfh.sendGetRequest(client.getOutputStream(), sanitizedUri); + } + + private String sanitizeUri(String uri) { + if (uri == null || uri.isEmpty()) return "index.html"; + + int endIndex = Math.min( + uri.indexOf('?') < 0 ? uri.length() : uri.indexOf('?'), + uri.indexOf('#') < 0 ? uri.length() : uri.indexOf('#') + ); + + return uri.substring(0, endIndex) + .replace("\0", "") + .replaceAll("^/+", "") + .replaceAll("^$", "index.html"); } private HttpResponseBuilder applyFilters(HttpRequest request) { HttpResponseBuilder response = new HttpResponseBuilder(); - FilterChainImpl chain = new FilterChainImpl(filters); chain.doFilter(request, response); - return response; } - private void resolveTargetFile(String uri) { - if (uri.matches("/$")) { //matches(/) - this.uri = "index.html"; - } else if (uri.matches("^(?!.*\\.html$).*$")) { - this.uri = uri.concat(".html"); - } else { - this.uri = uri; - } - - } - @Override public void close() throws Exception { client.close(); @@ -97,19 +113,16 @@ public void close() throws Exception { private IpFilter createIpFilterFromConfig(AppConfig.IpFilterConfig config) { IpFilter filter = new IpFilter(); - // Set mode if ("ALLOWLIST".equalsIgnoreCase(config.mode())) { filter.setMode(IpFilter.FilterMode.ALLOWLIST); } else { filter.setMode(IpFilter.FilterMode.BLOCKLIST); } - // Add blocked IPs for (String ip : config.blockedIps()) { filter.addBlockedIp(ip); } - // Add allowed IPs for (String ip : config.allowedIps()) { filter.addAllowedIp(ip); } From f3bb1acbe06c7cf56838e319f69050df88e1a6b7 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Thu, 26 Feb 2026 11:20:04 +0100 Subject: [PATCH 32/36] =?UTF-8?q?daniel=20har=20hj=C3=A4lp=20mig=20med=20?= =?UTF-8?q?=C3=A4ndringar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/ConnectionHandler.java | 31 ++++++++++++++----- .../java/org/example/StaticFileHandler.java | 25 +++------------ 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 2e6d0cb7..78f424bd 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -4,6 +4,10 @@ import org.example.filter.IpFilter; import org.example.httpparser.HttpParser; import org.example.httpparser.HttpRequest; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.util.ArrayList; import java.util.List; import org.example.filter.Filter; @@ -69,19 +73,30 @@ public void runConnectionHandler() throws IOException { // Check cache FIRST byte[] cachedBytes = FileCache.get(cacheKey); if (cachedBytes != null) { - System.out.println("✓ Cache HIT: " + sanitizedUri); - HttpResponseBuilder cachedResp = new HttpResponseBuilder(); - cachedResp.setContentTypeFromFilename(sanitizedUri); - cachedResp.setBody(cachedBytes); - client.getOutputStream().write(cachedResp.build()); + System.out.println(" Cache HIT: " + sanitizedUri); + response.setContentTypeFromFilename(sanitizedUri); + response.setBody(cachedBytes); + client.getOutputStream().write(response.build()); client.getOutputStream().flush(); return; } // Cache miss - StaticFileHandler reads and caches - System.out.println("✗ Cache MISS: " + sanitizedUri); - StaticFileHandler sfh = new StaticFileHandler(); - sfh.sendGetRequest(client.getOutputStream(), sanitizedUri); + System.out.println(" Cache MISS: " + sanitizedUri); + try { + byte[] fileBytes = Files.readAllBytes(new File("www", sanitizedUri).toPath()); + FileCache.put(cacheKey, fileBytes); // ← SPARAR I CACHEN HÄR + + response.setContentTypeFromFilename(sanitizedUri); + response.setBody(fileBytes); + client.getOutputStream().write(response.build()); + client.getOutputStream().flush(); + } catch (NoSuchFileException e) { + response.setStatusCode(HttpResponseBuilder.SC_NOT_FOUND); + response.setBody("404 Not Found"); + client.getOutputStream().write(response.build()); + client.getOutputStream().flush(); + } } private String sanitizeUri(String uri) { diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 67cf5454..8f7e693f 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -24,24 +24,22 @@ public StaticFileHandler(String webRoot) { } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { - String sanitizedUri = sanitizeUri(uri); - - if (isPathTraversal(sanitizedUri)) { + if (isPathTraversal(uri)) { writeResponse(outputStream, 403, "Forbidden"); return; } try { - String cacheKey = webRoot + ":" + sanitizedUri; + String cacheKey = "www:" + uri; byte[] fileBytes = FileCache.get(cacheKey); if (fileBytes == null) { - fileBytes = Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()); + fileBytes = Files.readAllBytes(new File(webRoot, uri).toPath()); FileCache.put(cacheKey, fileBytes); } HttpResponseBuilder response = new HttpResponseBuilder(); - response.setContentTypeFromFilename(sanitizedUri); + response.setContentTypeFromFilename(uri); response.setBody(fileBytes); outputStream.write(response.build()); outputStream.flush(); @@ -53,21 +51,6 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep } } - - private String sanitizeUri(String uri) { - if (uri == null || uri.isEmpty()) return "index.html"; - - int endIndex = Math.min( - uri.indexOf('?') < 0 ? uri.length() : uri.indexOf('?'), - uri.indexOf('#') < 0 ? uri.length() : uri.indexOf('#') - ); - - return uri.substring(0, endIndex) - .replace("\0", "") - .replaceAll("^/+", "") - .replaceAll("^$", "index.html"); - } - private boolean isPathTraversal(String uri) { try { Path webRootPath = Paths.get(webRoot).toAbsolutePath().normalize(); From 2f8312b0359d4972919c52216adff7e2baa74ca5 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Thu, 26 Feb 2026 11:43:54 +0100 Subject: [PATCH 33/36] =?UTF-8?q?fixat=20s=C3=A5=20uri=20rensas=20och=20?= =?UTF-8?q?=C3=A5teranv=C3=A4nds,=20lade=20till=20ny=20metod=20f=C3=B6r=20?= =?UTF-8?q?sanering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/ConnectionHandler.java | 28 ------------------- .../java/org/example/StaticFileHandler.java | 25 ++++++++++++++--- 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 78f424bd..9fb2c7f6 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -69,34 +69,6 @@ public void runConnectionHandler() throws IOException { // Sanitize URI here (clean it) String sanitizedUri = sanitizeUri(parser.getUri()); String cacheKey = "www:" + sanitizedUri; - - // Check cache FIRST - byte[] cachedBytes = FileCache.get(cacheKey); - if (cachedBytes != null) { - System.out.println(" Cache HIT: " + sanitizedUri); - response.setContentTypeFromFilename(sanitizedUri); - response.setBody(cachedBytes); - client.getOutputStream().write(response.build()); - client.getOutputStream().flush(); - return; - } - - // Cache miss - StaticFileHandler reads and caches - System.out.println(" Cache MISS: " + sanitizedUri); - try { - byte[] fileBytes = Files.readAllBytes(new File("www", sanitizedUri).toPath()); - FileCache.put(cacheKey, fileBytes); // ← SPARAR I CACHEN HÄR - - response.setContentTypeFromFilename(sanitizedUri); - response.setBody(fileBytes); - client.getOutputStream().write(response.build()); - client.getOutputStream().flush(); - } catch (NoSuchFileException e) { - response.setStatusCode(HttpResponseBuilder.SC_NOT_FOUND); - response.setBody("404 Not Found"); - client.getOutputStream().write(response.build()); - client.getOutputStream().flush(); - } } private String sanitizeUri(String uri) { diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 8f7e693f..098e779e 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -24,22 +24,24 @@ public StaticFileHandler(String webRoot) { } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { - if (isPathTraversal(uri)) { + String sanitizedUri = sanitizeUri(uri); + + if (isPathTraversal(sanitizedUri)) { writeResponse(outputStream, 403, "Forbidden"); return; } try { - String cacheKey = "www:" + uri; + String cacheKey = "www:" + sanitizedUri; byte[] fileBytes = FileCache.get(cacheKey); if (fileBytes == null) { - fileBytes = Files.readAllBytes(new File(webRoot, uri).toPath()); + fileBytes = Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()); FileCache.put(cacheKey, fileBytes); } HttpResponseBuilder response = new HttpResponseBuilder(); - response.setContentTypeFromFilename(uri); + response.setContentTypeFromFilename(sanitizedUri); response.setBody(fileBytes); outputStream.write(response.build()); outputStream.flush(); @@ -51,6 +53,21 @@ public void sendGetRequest(OutputStream outputStream, String uri) throws IOExcep } } + + private String sanitizeUri(String uri) { + if (uri == null || uri.isEmpty()) return "index.html"; + + int endIndex = Math.min( + uri.indexOf('?') < 0 ? uri.length() : uri.indexOf('?'), + uri.indexOf('#') < 0 ? uri.length() : uri.indexOf('#') + ); + + return uri.substring(0, endIndex) + .replace("\0", "") + .replaceAll("^/+", "") + .replaceAll("^$", "index.html"); + } + private boolean isPathTraversal(String uri) { try { Path webRootPath = Paths.get(webRoot).toAbsolutePath().normalize(); From 331615af4a91fee0a7a0d0e457fd783431fa470d Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Thu, 26 Feb 2026 12:23:56 +0100 Subject: [PATCH 34/36] fixa compleir issue --- src/main/java/org/example/ConnectionHandler.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 75858ec0..9561a761 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -82,9 +82,9 @@ public void runConnectionHandler() throws IOException { return; } - // Sanitize URI here (clean it) - String sanitizedUri = sanitizeUri(parser.getUri()); - String cacheKey = "www:" + sanitizedUri; + // Let StaticFileHandler handle everything + StaticFileHandler sfh = new StaticFileHandler(); + sfh.sendGetRequest(client.getOutputStream(), parser.getUri()); } private String sanitizeUri(String uri) { From 1be780f0c857a2be4fa288d297a2136f1168e9ee Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Thu, 26 Feb 2026 12:32:46 +0100 Subject: [PATCH 35/36] =?UTF-8?q?beh=C3=B6vde=20fixa=20saker=20efter=20en?= =?UTF-8?q?=20rebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/ConnectionHandler.java | 42 ++----------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 9561a761..11cd5060 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -4,10 +4,6 @@ import org.example.filter.IpFilter; import org.example.httpparser.HttpParser; import org.example.httpparser.HttpRequest; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.NoSuchFileException; import java.util.ArrayList; import java.util.List; import org.example.filter.Filter; @@ -21,12 +17,12 @@ public class ConnectionHandler implements AutoCloseable { private final Socket client; private final List filters; - String webRoot; + private final String webRoot; public ConnectionHandler(Socket client) { this.client = client; + this.webRoot = "www"; this.filters = buildFilters(); - this.webRoot = null; } public ConnectionHandler(Socket client, String webRoot) { @@ -46,14 +42,6 @@ private List buildFilters() { } public void runConnectionHandler() throws IOException { - StaticFileHandler sfh; - - if (webRoot != null) { - sfh = new StaticFileHandler(webRoot); - } else { - sfh = new StaticFileHandler(); - } - HttpParser parser = new HttpParser(); parser.setReader(client.getInputStream()); parser.parseRequest(); @@ -83,26 +71,10 @@ public void runConnectionHandler() throws IOException { } // Let StaticFileHandler handle everything - StaticFileHandler sfh = new StaticFileHandler(); + StaticFileHandler sfh = new StaticFileHandler(webRoot); sfh.sendGetRequest(client.getOutputStream(), parser.getUri()); } - private String sanitizeUri(String uri) { - if (uri == null || uri.isEmpty()) return "index.html"; - - int endIndex = Math.min( - uri.indexOf('?') < 0 ? uri.length() : uri.indexOf('?'), - uri.indexOf('#') < 0 ? uri.length() : uri.indexOf('#') - ); - - return uri.substring(0, endIndex) - .replace("\0", "") - .replaceAll("^/+", "") - .replaceAll("^$", "index.html"); - resolveTargetFile(parser.getUri()); - sfh.sendGetRequest(client.getOutputStream(), uri); - } - private HttpResponseBuilder applyFilters(HttpRequest request) { HttpResponseBuilder response = new HttpResponseBuilder(); FilterChainImpl chain = new FilterChainImpl(filters); @@ -110,14 +82,6 @@ private HttpResponseBuilder applyFilters(HttpRequest request) { return response; } - private void resolveTargetFile(String uri) { - if (uri == null || "/".equals(uri)) { - this.uri = "index.html"; - } else { - this.uri = uri.startsWith("/") ? uri.substring(1) : uri; - } - } - @Override public void close() throws Exception { client.close(); From 56ba5201b03b70c17b72aa83554c685a6786be37 Mon Sep 17 00:00:00 2001 From: Torsten Wihlborg Date: Sat, 28 Feb 2026 15:41:10 +0100 Subject: [PATCH 36/36] should have made it according to the mall given --- src/main/java/org/example/App.java | 3 +- .../java/org/example/ConnectionHandler.java | 84 ++++++++----- src/main/java/org/example/FileCache.java | 28 +++-- .../java/org/example/StaticFileHandler.java | 85 ++++--------- src/main/java/org/example/TcpServer.java | 17 +-- .../org/example/http/HttpResponseBuilder.java | 12 ++ .../org/example/ConnectionHandlerTest.java | 30 ++++- .../org/example/StaticFileHandlerTest.java | 117 +++++++----------- src/test/java/org/example/TcpServerTest.java | 4 +- 9 files changed, 188 insertions(+), 192 deletions(-) diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index 966e9563..5b035a1e 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -17,7 +17,8 @@ public static void main(String[] args) { int port = resolvePort(args, appConfig.server().port()); - new TcpServer(port, ConnectionHandler::new).start(); + FileCache fileCache = new FileCache(10); + new TcpServer(port, socket -> new ConnectionHandler(socket, fileCache)).start(); } static int resolvePort(String[] args, int configPort) { diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 11cd5060..6f3111a4 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -14,20 +14,23 @@ import java.io.IOException; import java.net.Socket; +import java.io.OutputStream; +import java.nio.file.NoSuchFileException; + public class ConnectionHandler implements AutoCloseable { private final Socket client; private final List filters; private final String webRoot; + private final FileCache fileCache; - public ConnectionHandler(Socket client) { - this.client = client; - this.webRoot = "www"; - this.filters = buildFilters(); + public ConnectionHandler(Socket client, FileCache fileCache) { + this(client, "www", fileCache); } - public ConnectionHandler(Socket client, String webRoot) { + public ConnectionHandler(Socket client, String webRoot, FileCache fileCache) { this.client = client; this.webRoot = webRoot; + this.fileCache = fileCache; this.filters = buildFilters(); } @@ -42,44 +45,67 @@ private List buildFilters() { } public void runConnectionHandler() throws IOException { + HttpParser parser = parseRequest(); + HttpRequest request = buildHttpRequest(parser); + + if (isForbiddenByFilters(request)) return; + if (isPathTraversal(parser.getUri())) return; + + serveFile(parser.getUri()); + } + + private HttpParser parseRequest() throws IOException { HttpParser parser = new HttpParser(); parser.setReader(client.getInputStream()); parser.parseRequest(); parser.parseHttp(); + return parser; + } + private HttpRequest buildHttpRequest(HttpParser parser) { HttpRequest request = new HttpRequest( - parser.getMethod(), - parser.getUri(), - parser.getVersion(), - parser.getHeadersMap(), - "" + parser.getMethod(), parser.getUri(), parser.getVersion(), + parser.getHeadersMap(), "" ); + request.setAttribute("clientIp", client.getInetAddress().getHostAddress()); + return request; + } - String clientIp = client.getInetAddress().getHostAddress(); - request.setAttribute("clientIp", clientIp); - - // Apply security filters - HttpResponseBuilder response = applyFilters(request); + private boolean isForbiddenByFilters(HttpRequest request) throws IOException { + HttpResponseBuilder response = new HttpResponseBuilder(); + new FilterChainImpl(filters).doFilter(request, response); - int statusCode = response.getStatusCode(); - if (statusCode == HttpResponseBuilder.SC_FORBIDDEN || - statusCode == HttpResponseBuilder.SC_BAD_REQUEST) { - byte[] responseBytes = response.build(); - client.getOutputStream().write(responseBytes); + int status = response.getStatusCode(); + if (status == HttpResponseBuilder.SC_FORBIDDEN || status == HttpResponseBuilder.SC_BAD_REQUEST) { + client.getOutputStream().write(response.build()); client.getOutputStream().flush(); - return; + return true; + } + return false; + } + + private boolean isPathTraversal(String uri) throws IOException { + if (uri.contains("..")) { + sendErrorResponse(client.getOutputStream(), 403, "Forbidden"); + return true; } + return false; + } - // Let StaticFileHandler handle everything - StaticFileHandler sfh = new StaticFileHandler(webRoot); - sfh.sendGetRequest(client.getOutputStream(), parser.getUri()); + private void serveFile(String uri) throws IOException { + StaticFileHandler sfh = new StaticFileHandler(webRoot, fileCache); + try { + sfh.sendGetRequest(client.getOutputStream(), uri); + } catch (NoSuchFileException e) { + sendErrorResponse(client.getOutputStream(), 404, "Not Found"); + } catch (Exception e) { + sendErrorResponse(client.getOutputStream(), 500, "Internal Server Error"); + } } - private HttpResponseBuilder applyFilters(HttpRequest request) { - HttpResponseBuilder response = new HttpResponseBuilder(); - FilterChainImpl chain = new FilterChainImpl(filters); - chain.doFilter(request, response); - return response; + private void sendErrorResponse(OutputStream out, int statusCode, String message) throws IOException { + out.write(HttpResponseBuilder.createErrorResponse(statusCode, message)); + out.flush(); } @Override diff --git a/src/main/java/org/example/FileCache.java b/src/main/java/org/example/FileCache.java index 9f4b8826..166053d3 100644 --- a/src/main/java/org/example/FileCache.java +++ b/src/main/java/org/example/FileCache.java @@ -1,22 +1,24 @@ package org.example; -import java.util.concurrent.ConcurrentHashMap; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; public class FileCache { - private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private final Map cache; - private FileCache() {} - - public static byte[] get(String key) { - return cache.get(key); - } - - public static void put(String key, byte[] content) { - cache.put(key, content); + public FileCache(int maxSize) { + this.cache = Collections.synchronizedMap(new LinkedHashMap(maxSize, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxSize; + } + }); } - public static void clear() { - cache.clear(); - } + public byte[] get(String key) { return cache.get(key); } + public void put(String key, byte[] content) { cache.put(key, content); } + public void clear() { cache.clear(); } + public int size() { return cache.size(); } } diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java index 098e779e..e9023a51 100644 --- a/src/main/java/org/example/StaticFileHandler.java +++ b/src/main/java/org/example/StaticFileHandler.java @@ -2,92 +2,57 @@ package org.example; import org.example.http.HttpResponseBuilder; -import java.io.File; import java.io.IOException; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.NoSuchFileException; - public class StaticFileHandler { private static final String DEFAULT_WEB_ROOT = "www"; private final String webRoot; + private final FileCache fileCache; - public StaticFileHandler() { - this(DEFAULT_WEB_ROOT); + public StaticFileHandler(FileCache fileCache) { + this(DEFAULT_WEB_ROOT, fileCache); } - public StaticFileHandler(String webRoot) { + public StaticFileHandler(String webRoot, FileCache fileCache) { this.webRoot = webRoot; + this.fileCache = fileCache; } public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { String sanitizedUri = sanitizeUri(uri); + Path filePath = Path.of(webRoot, sanitizedUri); - if (isPathTraversal(sanitizedUri)) { - writeResponse(outputStream, 403, "Forbidden"); - return; - } - - try { - String cacheKey = "www:" + sanitizedUri; - byte[] fileBytes = FileCache.get(cacheKey); - - if (fileBytes == null) { - fileBytes = Files.readAllBytes(new File(webRoot, sanitizedUri).toPath()); - FileCache.put(cacheKey, fileBytes); - } - - HttpResponseBuilder response = new HttpResponseBuilder(); - response.setContentTypeFromFilename(sanitizedUri); - response.setBody(fileBytes); - outputStream.write(response.build()); - outputStream.flush(); + // Använd fullständig sökväg som cache-nyckel + String cacheKey = filePath.toString(); + byte[] fileBytes = fileCache.get(cacheKey); - } catch (NoSuchFileException e) { - writeResponse(outputStream, 404, "Not Found"); - } catch (IOException e) { - writeResponse(outputStream, 500, "Internal Server Error"); + if (fileBytes == null) { + fileBytes = Files.readAllBytes(filePath); + fileCache.put(cacheKey, fileBytes); } - } + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setContentTypeFromFilename(sanitizedUri); + response.setBody(fileBytes); + outputStream.write(response.build()); + outputStream.flush(); + } private String sanitizeUri(String uri) { - if (uri == null || uri.isEmpty()) return "index.html"; - - int endIndex = Math.min( - uri.indexOf('?') < 0 ? uri.length() : uri.indexOf('?'), - uri.indexOf('#') < 0 ? uri.length() : uri.indexOf('#') - ); + if (uri == null || uri.isEmpty() || uri.equals("/")) return "index.html"; - return uri.substring(0, endIndex) - .replace("\0", "") + // Ta bort query, anchors, start-snedstreck och null-bytes + String cleanUri = uri.split("[?#]")[0] .replaceAll("^/+", "") - .replaceAll("^$", "index.html"); - } + .replace("\0", ""); - private boolean isPathTraversal(String uri) { - try { - Path webRootPath = Paths.get(webRoot).toAbsolutePath().normalize(); - Path requestedPath = webRootPath.resolve(uri).normalize(); - return !requestedPath.startsWith(webRootPath); - } catch (Exception e) { - return true; - } - } - - private void writeResponse(OutputStream outputStream, int statusCode, String statusMessage) throws IOException { - HttpResponseBuilder response = new HttpResponseBuilder(); - response.setStatusCode(statusCode); - response.setHeader("Content-Type", "text/html; charset=UTF-8"); - response.setBody(String.format("

%d %s

", statusCode, statusMessage)); - outputStream.write(response.build()); - outputStream.flush(); + return cleanUri.isEmpty() ? "index.html" : cleanUri; } - public static void clearCache() { - FileCache.clear(); + public void clearCache() { + fileCache.clear(); } } diff --git a/src/main/java/org/example/TcpServer.java b/src/main/java/org/example/TcpServer.java index e0a3655d..b6cc8cad 100644 --- a/src/main/java/org/example/TcpServer.java +++ b/src/main/java/org/example/TcpServer.java @@ -4,11 +4,8 @@ import java.io.IOException; import java.io.OutputStream; -import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; -import java.nio.charset.StandardCharsets; -import java.util.Map; public class TcpServer { @@ -43,29 +40,19 @@ protected void handleClient(Socket client) { } private void processRequest(Socket client) throws Exception { - ConnectionHandler handler = null; - try{ - handler = connectionFactory.create(client); + try (ConnectionHandler handler = connectionFactory.create(client)) { handler.runConnectionHandler(); } catch (Exception e) { handleInternalServerError(client); - } finally { - if(handler != null) - handler.close(); } } private void handleInternalServerError(Socket client){ - HttpResponseBuilder response = new HttpResponseBuilder(); - response.setStatusCode(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); - response.setHeaders(Map.of("Content-Type", "text/plain; charset=utf-8")); - response.setBody("⚠️ Internal Server Error 500 ⚠️"); - if (!client.isClosed()) { try { OutputStream out = client.getOutputStream(); - out.write(response.build()); + out.write(HttpResponseBuilder.createErrorResponse(500, "Internal Server Error")); out.flush(); } catch (IOException e) { System.err.println("Failed to send 500 response: " + e.getMessage()); diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index 9b6ed2a7..53050526 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -93,6 +93,18 @@ public void setContentTypeFromFilename(String filename) { setHeader("Content-Type", mimeType); } + public void setError(int statusCode, String message) { + setStatusCode(statusCode); + setHeader("Content-Type", "text/html; charset=UTF-8"); + setBody(String.format("

%d %s

", statusCode, message)); + } + + public static byte[] createErrorResponse(int statusCode, String message) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setError(statusCode, message); + return builder.build(); + } + /* * Builds the complete HTTP response as a byte array and preserves binary content without corruption. * @return Complete HTTP response (headers + body) as byte[] diff --git a/src/test/java/org/example/ConnectionHandlerTest.java b/src/test/java/org/example/ConnectionHandlerTest.java index ee366fa2..b55ddca8 100644 --- a/src/test/java/org/example/ConnectionHandlerTest.java +++ b/src/test/java/org/example/ConnectionHandlerTest.java @@ -28,6 +28,8 @@ class ConnectionHandlerTest { @TempDir Path tempDir; + private final FileCache cache = new FileCache(10); + @BeforeAll static void setupConfig() { ConfigLoader.resetForTests(); @@ -49,7 +51,7 @@ void test_jpg_file_should_return_200_not_404() throws Exception { when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); // Act - try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString(), cache)) { handler.runConnectionHandler(); } @@ -74,7 +76,7 @@ void test_root_path_should_serve_index_html() throws Exception { when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); // Act - try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString(), cache)) { handler.runConnectionHandler(); } @@ -96,7 +98,7 @@ void test_missing_file_should_return_404() throws Exception { when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); // Act - try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString(), cache)) { handler.runConnectionHandler(); } @@ -104,4 +106,26 @@ void test_missing_file_should_return_404() throws Exception { String response = outputStream.toString(); assertThat(response).contains("404"); } + + @Test + void test_path_traversal_should_return_403() throws Exception { + // Arrange + String request = "GET /../secret.txt HTTP/1.1\r\nHost: localhost\r\n\r\n"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(inputStream); + when(socket.getOutputStream()).thenReturn(outputStream); + when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); + + // Act + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString(), cache)) { + handler.runConnectionHandler(); + } + + // Assert + String response = outputStream.toString(); + assertThat(response).contains("403"); + assertThat(response).contains("Forbidden"); + } } \ No newline at end of file diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java index 49c62bf5..0ac47a1f 100644 --- a/src/test/java/org/example/StaticFileHandlerTest.java +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -3,12 +3,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.assertThat; @@ -27,8 +26,18 @@ */ class StaticFileHandlerTest { - private StaticFileHandler createHandler() throws IOException { - return new StaticFileHandler(tempDir.toString()); + @TempDir + Path tempDir; + + private FileCache cache; + + @BeforeEach + void setUp() { + cache = new FileCache(10); + } + + private StaticFileHandler createHandler(){ + return new StaticFileHandler(tempDir.toString(), cache); } private String sendRequest(String uri) throws IOException { @@ -38,15 +47,6 @@ private String sendRequest(String uri) throws IOException { return output.toString(); } - @TempDir - Path tempDir; - - @BeforeEach - void setUp() { - // Rensa cache innan varje test för clean state - StaticFileHandler.clearCache(); - } - @Test void testCaching_HitOnSecondRequest() throws IOException { // Arrange @@ -75,15 +75,17 @@ void testSanitization_LeadingSlash() throws IOException { } @Test - void testSanitization_NullBytes() throws IOException { - assertThat(sendRequest("file.html\0../../secret")).contains("HTTP/1.1 404"); + void testSanitization_NullBytes() { + assertThrows(NoSuchFileException.class, () -> { + sendRequest("file.html\0../../secret"); + }); } @Test void testConcurrent_MultipleReads() throws InterruptedException, IOException { // Arrange Files.writeString(tempDir.resolve("shared.html"), "Data"); - StaticFileHandler handler = new StaticFileHandler(tempDir.toString()); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString(), cache); handler.sendGetRequest(new ByteArrayOutputStream(), "shared.html"); @@ -127,7 +129,7 @@ void test_file_that_exists_should_return_200() throws IOException { Path testFile = tempDir.resolve("test.html"); Files.writeString(testFile, "Hello Test"); - StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); + StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString(), cache); ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); // Act @@ -141,73 +143,50 @@ void test_file_that_exists_should_return_200() throws IOException { } @Test - void test_file_that_does_not_exists_should_return_404() throws IOException { + void test_file_that_does_not_exists_should_throw_exception() { // Arrange - StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); + StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString(), cache); ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); - // Act - staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); - - // Assert - String response = fakeOutput.toString(); - assertTrue(response.contains("HTTP/1.1 404 Not Found")); + // Act & Assert + assertThrows(NoSuchFileException.class, () -> { + staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); + }); } @Test - void test_path_traversal_should_return_403() throws IOException { + void null_byte_injection_should_throw_exception() throws IOException { // Arrange - Path secret = tempDir.resolve("secret.txt"); - Files.writeString(secret, "TOP SECRET"); Path webRoot = tempDir.resolve("www"); Files.createDirectories(webRoot); - StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); - ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); - - // Act - handler.sendGetRequest(fakeOutput, "../secret.txt"); - - // Assert - String response = fakeOutput.toString(); - assertFalse(response.contains("TOP SECRET")); - assertTrue(response.contains("HTTP/1.1 403 Forbidden")); - } - - @ParameterizedTest - @CsvSource({ - "index.html?foo=bar", - "index.html#section", - "/index.html" - }) - void sanitized_uris_should_return_200(String uri) throws IOException { - // Arrange - Path webRoot = tempDir.resolve("www"); - Files.createDirectories(webRoot); - Files.writeString(webRoot.resolve("index.html"), "Hello"); - StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); + StaticFileHandler handler = new StaticFileHandler(webRoot.toString(), cache); ByteArrayOutputStream out = new ByteArrayOutputStream(); - // Act - handler.sendGetRequest(out, uri); - - // Assert - assertTrue(out.toString().contains("HTTP/1.1 200 OK")); + // Act & Assert + assertThrows(NoSuchFileException.class, () -> { + handler.sendGetRequest(out, "index.html\0../../etc/passwd"); + }); } @Test - void null_byte_injection_should_not_return_200() throws IOException { - // Arrange - Path webRoot = tempDir.resolve("www"); - Files.createDirectories(webRoot); - StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); - ByteArrayOutputStream out = new ByteArrayOutputStream(); + void testMaxCacheSize() throws IOException { + FileCache limitedCache = new FileCache(2); + StaticFileHandler handler = new StaticFileHandler(tempDir.toString(), limitedCache); - // Act - handler.sendGetRequest(out, "index.html\0../../etc/passwd"); + Files.writeString(tempDir.resolve("1.html"), "1"); + Files.writeString(tempDir.resolve("2.html"), "2"); + Files.writeString(tempDir.resolve("3.html"), "3"); - // Assert - String response = out.toString(); - assertFalse(response.contains("HTTP/1.1 200 OK")); - assertTrue(response.contains("HTTP/1.1 404 Not Found")); + handler.sendGetRequest(new ByteArrayOutputStream(), "1.html"); + handler.sendGetRequest(new ByteArrayOutputStream(), "2.html"); + assertEquals(2, limitedCache.size()); + + handler.sendGetRequest(new ByteArrayOutputStream(), "3.html"); + assertEquals(2, limitedCache.size()); + + // 1.html bör ha tagits bort eftersom det var äldst (LRU) + assertNull(limitedCache.get(tempDir.resolve("1.html").toString())); + assertNotNull(limitedCache.get(tempDir.resolve("2.html").toString())); + assertNotNull(limitedCache.get(tempDir.resolve("3.html").toString())); } } diff --git a/src/test/java/org/example/TcpServerTest.java b/src/test/java/org/example/TcpServerTest.java index a62b3e95..9b62f19e 100644 --- a/src/test/java/org/example/TcpServerTest.java +++ b/src/test/java/org/example/TcpServerTest.java @@ -33,8 +33,8 @@ void failedClientRequestShouldReturnError500() throws Exception{ String response = outputStream.toString(); assertAll( () -> assertTrue(response.contains("500")), - () -> assertTrue(response.contains("Internal Server Error 500")), - () -> assertTrue(response.contains("Content-Type: text/plain")) + () -> assertTrue(response.contains("Internal Server Error")), + () -> assertTrue(response.contains("Content-Type: text/html")) ); } }