From 251c3dc5e88337e811c57505d55120bc280b230d Mon Sep 17 00:00:00 2001
From: Martin Lowe <martin.lowe@eclipse-foundation.org>
Date: Thu, 7 Sep 2023 15:22:18 -0400
Subject: [PATCH] Fix issue with cache param map leaks, add fuzzy remove tests

Param maps weren't being properly cloned before being used as the keys,
meaning that the key could be modified after being used. This led to
some weird behaviour where keys would duplicate themselves.

Additionally added some fuzzy key removal tests to validate the
functionality as there were some issues in a downstream project that
used the call. There were no issues in code for fuzzyRemove, so it was
left unchanged.
---
 .../service/impl/QuarkusCachingService.java   | 27 ++++---
 .../DefaultQuarkusCachingServiceTest.java     | 80 +++++++++++++++++++
 2 files changed, 98 insertions(+), 9 deletions(-)

diff --git a/core/src/main/java/org/eclipsefoundation/core/service/impl/QuarkusCachingService.java b/core/src/main/java/org/eclipsefoundation/core/service/impl/QuarkusCachingService.java
index 4229cbd2..c1f40407 100644
--- a/core/src/main/java/org/eclipsefoundation/core/service/impl/QuarkusCachingService.java
+++ b/core/src/main/java/org/eclipsefoundation/core/service/impl/QuarkusCachingService.java
@@ -53,7 +53,7 @@ import io.quarkus.cache.CaffeineCache;
 @ApplicationScoped
 public class QuarkusCachingService implements CachingService {
     private static final Logger LOGGER = LoggerFactory.getLogger(QuarkusCachingService.class);
-    
+
     @ConfigProperty(name = MicroprofilePropertyNames.CACHE_TTL_MAX_SECONDS, defaultValue = "900")
     long ttlWrite;
 
@@ -67,13 +67,8 @@ public class QuarkusCachingService implements CachingService {
     @Override
     public <T> Optional<T> get(String id, MultivaluedMap<String, String> params, Class<?> rawType, Callable<? extends T> callable) {
         Objects.requireNonNull(callable);
-
-        ParameterizedCacheKey cacheKey = ParameterizedCacheKey
-                .builder()
-                .setClazz(rawType)
-                .setId(id)
-                .setParams(params != null ? params : new MultivaluedMapImpl<>())
-                .build();
+        // create the cache key for the entry, cloning the map for key integrity
+        ParameterizedCacheKey cacheKey = ParameterizedCacheKey.builder().setClazz(rawType).setId(id).setParams(cloneMap(params)).build();
         LOGGER.debug("Retrieving cache value for '{}'", cacheKey);
         return Optional.ofNullable(get(cacheKey, callable));
     }
@@ -167,7 +162,7 @@ public class QuarkusCachingService implements CachingService {
     }
 
     @CacheResult(cacheName = "default")
-    public <T> T get(@CacheKey ParameterizedCacheKey cacheKey, Callable<? extends T> callable) {
+    <T> T get(@CacheKey ParameterizedCacheKey cacheKey, Callable<? extends T> callable) {
         // attempt to get the result for the cache entry
         T out = null;
         try {
@@ -189,4 +184,18 @@ public class QuarkusCachingService implements CachingService {
         return System.currentTimeMillis() + getMaxAge();
     }
 
+    /**
+     * To prevent modification of maps/cache key entries post retrieval, we should be referencing copies of the maps rather
+     * than the direct passed reference for safety.
+     * 
+     * @return the copied map, or an empty map if null
+     */
+    private MultivaluedMap<String, String> cloneMap(MultivaluedMap<String, String> paramMap) {
+        MultivaluedMap<String, String> out = new MultivaluedMapImpl<>();
+        if (paramMap != null) {
+            paramMap.entrySet().forEach(e -> out.addAll(e.getKey(), e.getValue()));
+        }
+        return out;
+    }
+
 }
diff --git a/core/src/test/java/org/eclipsefoundation/core/service/impl/DefaultQuarkusCachingServiceTest.java b/core/src/test/java/org/eclipsefoundation/core/service/impl/DefaultQuarkusCachingServiceTest.java
index 2caf9b8f..222704d6 100644
--- a/core/src/test/java/org/eclipsefoundation/core/service/impl/DefaultQuarkusCachingServiceTest.java
+++ b/core/src/test/java/org/eclipsefoundation/core/service/impl/DefaultQuarkusCachingServiceTest.java
@@ -250,6 +250,86 @@ class DefaultQuarkusCachingServiceTest {
                         "Unknown key should not have been returned by getCacheKey, but was returned");
     }
 
+    /*
+     * fuzzyRemove(id, type)
+     */
+
+    @Test
+    void fuzzyRemove_success() {
+        // generate a random key
+        String id = UUID.randomUUID().toString();
+        // create empty map for initial post
+        MultivaluedMap<String, String> params = new MultivaluedMapImpl<>();
+        // check that we have no match for ID before proceeding
+        Assertions.assertFalse(lookupCacheKeyUsingMainCache(id, Optional.empty()), "Expected random UUID key to be empty, but found value");
+        // request + put the data in cache and check it's there
+        svc.get(id, params, String.class, () -> "sample");
+        Assertions
+                .assertTrue(lookupCacheKeyUsingMainCache(id, Optional.empty()),
+                        "Expected new cache value to have a key present in cache key set with empty map");
+
+        // update the map and create another entry
+        params = new MultivaluedMapImpl<>();
+        params.add(DefaultUrlParameterNames.IDS_PARAMETER_NAME, id);
+        Assertions
+                .assertFalse(lookupCacheKeyUsingMainCache(id, Optional.of(params)),
+                        "Expected random UUID key to be empty, but found value");
+        // request + put the data in cache and check it's there
+        svc.get(id, params, String.class, () -> "sample");
+        Assertions
+                .assertTrue(lookupCacheKeyUsingMainCache(id, Optional.of(params)),
+                        "Expected new cache value to have a key present in cache key set with empty map");
+
+        // do the fuzzy remove operation
+        svc.fuzzyRemove(id, String.class);
+
+        // check that both of the values are gone
+        Assertions
+                .assertFalse(lookupCacheKeyUsingMainCache(id, Optional.empty()),
+                        "Expected random UUID key to be empty after fuzzy remove, but found value");
+        Assertions
+                .assertFalse(lookupCacheKeyUsingMainCache(id, Optional.of(params)),
+                        "Expected random UUID key + params to be empty after fuzzy remove, but found value");
+    }
+
+    @Test
+    void fuzzyRemove_success_onlyRemovesForSameType() {
+        // generate a random key
+        String id = UUID.randomUUID().toString();
+        // create empty map for initial post
+        MultivaluedMap<String, String> params = new MultivaluedMapImpl<>();
+        // check that we have no match for ID before proceeding
+        Assertions
+                .assertFalse(lookupCacheKeyUsingRawCache(id, Optional.of(params), Optional.of(String.class), cache),
+                        "Expected random UUID key to be empty, but found value");
+        // request + put the data in cache and check it's there
+        svc.get(id, params, String.class, () -> "sample");
+        Assertions
+                .assertTrue(lookupCacheKeyUsingRawCache(id, Optional.of(params), Optional.of(String.class), cache),
+                        "Expected new cache value to have a key present in cache key set with empty map");
+
+        // create another entry with the same ID for a different type
+        Assertions
+                .assertFalse(lookupCacheKeyUsingRawCache(id, Optional.of(params), Optional.of(Integer.class), cache),
+                        "Expected random UUID key to be empty, but found value");
+        // request + put the data in cache and check it's there
+        svc.get(id, params, Integer.class, () -> 1);
+        Assertions
+                .assertTrue(lookupCacheKeyUsingRawCache(id, Optional.of(params), Optional.of(Integer.class), cache),
+                        "Expected new cache value to have a key present in cache key set with empty map");
+
+        // do the fuzzy remove operation
+        svc.fuzzyRemove(id, String.class);
+
+        // check that only the targeted type is removed
+        Assertions
+                .assertFalse(lookupCacheKeyUsingRawCache(id, Optional.of(params), Optional.of(String.class), cache),
+                        "Expected random UUID key to be empty after fuzzy remove, but found value");
+        Assertions
+                .assertTrue(lookupCacheKeyUsingRawCache(id, Optional.of(params), Optional.of(Integer.class), cache),
+                        "Expected random UUID key + params to be empty after fuzzy remove, but found value");
+    }
+
     /*
      * remove(key) tests
      */
-- 
GitLab