This is an automated email from the ASF dual-hosted git repository.

smolnar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/knox.git


The following commit(s) were added to refs/heads/master by this push:
     new 7ecd609b9 KNOX-3035 - Limit response group header contents by size. 
(#1101)
7ecd609b9 is described below

commit 7ecd609b9fc8ebd9aa84e5de560fe5ff0130357b
Author: Sandor Molnar <[email protected]>
AuthorDate: Wed Oct 22 13:54:55 2025 +0200

    KNOX-3035 - Limit response group header contents by size. (#1101)
    
    Size-based group header partitioning takes precedence over length-based 
partitioning, iff, size-based partitioning is configured to a non-negative 
number.
---
 .../gateway/service/auth/AbstractAuthResource.java |  44 ++----
 .../gateway/service/auth/PreAuthResourceTest.java  |  18 +--
 .../gateway/dispatch/ConfigurableDispatch.java     |  54 +++----
 .../org/apache/knox/gateway/util/GroupUtils.java   | 109 ++++++++++++++
 .../apache/knox/gateway/util/GroupUtilsTest.java   | 166 +++++++++++++++++++++
 5 files changed, 321 insertions(+), 70 deletions(-)

diff --git 
a/gateway-service-auth/src/main/java/org/apache/knox/gateway/service/auth/AbstractAuthResource.java
 
b/gateway-service-auth/src/main/java/org/apache/knox/gateway/service/auth/AbstractAuthResource.java
index 61c9aed76..f59919584 100644
--- 
a/gateway-service-auth/src/main/java/org/apache/knox/gateway/service/auth/AbstractAuthResource.java
+++ 
b/gateway-service-auth/src/main/java/org/apache/knox/gateway/service/auth/AbstractAuthResource.java
@@ -19,13 +19,12 @@ package org.apache.knox.gateway.service.auth;
 
 import org.apache.knox.gateway.i18n.messages.MessagesFactory;
 import org.apache.knox.gateway.security.SubjectUtils;
+import org.apache.knox.gateway.util.GroupUtils;
 
 import javax.security.auth.Subject;
 import javax.servlet.ServletContext;
 import javax.servlet.http.HttpServletResponse;
 import javax.ws.rs.core.Response;
-import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
@@ -39,7 +38,9 @@ import static javax.ws.rs.core.Response.status;
 public abstract class AbstractAuthResource {
   public static final String AUTH_ACTOR_ID_HEADER_NAME = 
"preauth.auth.header.actor.id.name";
   public static final String AUTH_ACTOR_GROUPS_HEADER_PREFIX = 
"preauth.auth.header.actor.groups.prefix";
-  public static final String GROUP_FILTER_PATTERN = 
"preauth.group.filter.pattern";
+  public static final String GROUP_HEADER_LENGTH_LIMIT = 
"preauth.auth.header.groups.length.limit";
+  public static final String GROUP_HEADER_SIZE_LIMIT = 
"preauth.auth.header.groups.size.limit";
+  private static final String GROUP_FILTER_PATTERN = 
"preauth.group.filter.pattern";
 
   static final AuthMessages LOG = MessagesFactory.get(AuthMessages.class);
 
@@ -47,16 +48,21 @@ public abstract class AbstractAuthResource {
   static final String DEFAULT_AUTH_ACTOR_GROUPS_HEADER_PREFIX = 
"X-Knox-Actor-Groups";
   static final Pattern DEFAULT_GROUP_FILTER_PATTERN = Pattern.compile(".*");
 
-  protected static final int MAX_HEADER_LENGTH = 1000;
-  protected static final String ACTOR_GROUPS_HEADER_FORMAT = "%s-%d";
+  private static final String DEFAULT_GROUP_HEADER_LENGTH_LIMIT = "1000";
+  private static final String DEFAULT_GROUP_HEADER_SIZE_LIMIT = "-1"; // 
turned off by default, to be backward compatible
+  private static final String ACTOR_GROUPS_HEADER_FORMAT = "%s-%d";
 
   protected String authHeaderActorIDName;
   protected String authHeaderActorGroupsPrefix;
+  private int groupHeaderLengthLimit;
+  private int groupHeaderSizeLimit;
   protected Pattern groupFilterPattern;
 
   protected void initialize() {
     authHeaderActorIDName = getInitParameter(AUTH_ACTOR_ID_HEADER_NAME, 
DEFAULT_AUTH_ACTOR_ID_HEADER_NAME);
     authHeaderActorGroupsPrefix = 
getInitParameter(AUTH_ACTOR_GROUPS_HEADER_PREFIX, 
DEFAULT_AUTH_ACTOR_GROUPS_HEADER_PREFIX);
+    groupHeaderLengthLimit = 
Integer.parseInt(getInitParameter(GROUP_HEADER_LENGTH_LIMIT, 
DEFAULT_GROUP_HEADER_LENGTH_LIMIT));
+    groupHeaderSizeLimit = 
Integer.parseInt(getInitParameter(GROUP_HEADER_SIZE_LIMIT, 
DEFAULT_GROUP_HEADER_SIZE_LIMIT));
     final String groupFilterPatternString = 
getInitParameter(GROUP_FILTER_PATTERN, null);
     groupFilterPattern = groupFilterPatternString == null ? 
DEFAULT_GROUP_FILTER_PATTERN : Pattern.compile(groupFilterPatternString);
   }
@@ -84,10 +90,10 @@ public abstract class AbstractAuthResource {
 
     // Populate actor groups headers
     final Set<String> matchingGroupNames = subject == null ? 
Collections.emptySet()
-        : SubjectUtils.getGroupPrincipals(subject).stream().filter(group -> 
groupFilterPattern.matcher(group.getName()).matches()).map(group -> 
group.getName())
-        .collect(Collectors.toSet());
+            : SubjectUtils.getGroupPrincipals(subject).stream().filter(group 
-> groupFilterPattern.matcher(group.getName()).matches()).map(group -> 
group.getName())
+            .collect(Collectors.toSet());
     if (!matchingGroupNames.isEmpty()) {
-      final List<String> groupStrings = getGroupStrings(matchingGroupNames);
+      final List<String> groupStrings = 
GroupUtils.getGroupStrings(matchingGroupNames, groupHeaderLengthLimit, 
groupHeaderSizeLimit);
       for (int i = 0; i < groupStrings.size(); i++) {
         getResponse().addHeader(String.format(Locale.ROOT, 
ACTOR_GROUPS_HEADER_FORMAT, authHeaderActorGroupsPrefix, i + 1), 
groupStrings.get(i));
       }
@@ -95,26 +101,4 @@ public abstract class AbstractAuthResource {
     return ok().build();
   }
 
-  private List<String> getGroupStrings(Collection<String> groupNames) {
-    if (groupNames.isEmpty()) {
-      return Collections.emptyList();
-    }
-    List<String> groupStrings = new ArrayList<>();
-    StringBuilder sb = new StringBuilder();
-    for (String groupName : groupNames) {
-      if (sb.length() + groupName.length() > MAX_HEADER_LENGTH) {
-        groupStrings.add(sb.toString());
-        sb = new StringBuilder();
-      }
-      if (sb.length() > 0) {
-        sb.append(',');
-      }
-      sb.append(groupName);
-    }
-    if (sb.length() > 0) {
-      groupStrings.add(sb.toString());
-    }
-    return groupStrings;
-  }
-
 }
diff --git 
a/gateway-service-auth/src/test/java/org/apache/knox/gateway/service/auth/PreAuthResourceTest.java
 
b/gateway-service-auth/src/test/java/org/apache/knox/gateway/service/auth/PreAuthResourceTest.java
index 4cb3b12e3..01ca3c5e2 100644
--- 
a/gateway-service-auth/src/test/java/org/apache/knox/gateway/service/auth/PreAuthResourceTest.java
+++ 
b/gateway-service-auth/src/test/java/org/apache/knox/gateway/service/auth/PreAuthResourceTest.java
@@ -44,7 +44,6 @@ public class PreAuthResourceTest {
 
   private static final String USER_NAME = "test-username";
   private ServletContext context;
-  private HttpServletRequest request;
   private HttpServletResponse response;
   private final Subject subject = new Subject();
 
@@ -53,15 +52,15 @@ public class PreAuthResourceTest {
     subject.getPrincipals().add(new PrimaryPrincipal(USER_NAME));
   }
 
-  private void configureCommonExpectations(String actorIdHeaderName, String 
groupsHeaderPrefix) {
-    configureCommonExpectations(actorIdHeaderName, groupsHeaderPrefix, 
Collections.emptySet());
+  private void configureCommonExpectations(String actorIdHeaderName) {
+    configureCommonExpectations(actorIdHeaderName, null, 
Collections.emptySet());
   }
 
   private void configureCommonExpectations(String actorIdHeaderName, String 
groupsHeaderPrefix, Collection<String> groups) {
     context = EasyMock.createNiceMock(ServletContext.class);
     
EasyMock.expect(context.getInitParameter(PreAuthResource.AUTH_ACTOR_ID_HEADER_NAME)).andReturn(actorIdHeaderName).anyTimes();
     
EasyMock.expect(context.getInitParameter(PreAuthResource.AUTH_ACTOR_GROUPS_HEADER_PREFIX)).andReturn(groupsHeaderPrefix).anyTimes();
-    request = EasyMock.createNiceMock(HttpServletRequest.class);
+    final HttpServletRequest request = 
EasyMock.createNiceMock(HttpServletRequest.class);
     response = EasyMock.createNiceMock(HttpServletResponse.class);
 
     if (SubjectUtils.getPrimaryPrincipalName(subject) != null) {
@@ -75,12 +74,13 @@ public class PreAuthResourceTest {
       final int groupStringSize = calculateGroupStringSize(groups);
       final int expectedGroupHeaderCount = groupStringSize / 1000 + 1;
       final String expectedGroupsHeaderPrefix = (groupsHeaderPrefix == null ? 
PreAuthResource.DEFAULT_AUTH_ACTOR_GROUPS_HEADER_PREFIX : groupsHeaderPrefix)
-          + "-";
+              + "-";
       for (int i = 1; i <= expectedGroupHeaderCount; i++) {
         response.addHeader(EasyMock.eq(expectedGroupsHeaderPrefix + i), 
EasyMock.anyString());
         EasyMock.expectLastCall();
       }
     }
+
     EasyMock.replay(context, request, response);
   }
 
@@ -94,17 +94,17 @@ public class PreAuthResourceTest {
   @Test
   public void testSubjectWithoutPrimaryPrincipalReturnsUnauthorized() throws 
Exception {
     subject.getPrincipals().clear();
-    configureCommonExpectations(null, null);
+    configureCommonExpectations(null);
     final PreAuthResource preAuthResource = new PreAuthResource();
     preAuthResource.context = context;
     preAuthResource.response = response;
     final Response response = executeResourceWithSubject(preAuthResource);
-    assertEquals(response.getStatus(), HttpServletResponse.SC_UNAUTHORIZED);
+    assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
   }
 
   @Test
   public void testPopulatingDefaultActorIdHeaderNoGroups() throws Exception {
-    configureCommonExpectations(null, null);
+    configureCommonExpectations(null);
     final PreAuthResource preAuthResource = new PreAuthResource();
     preAuthResource.context = context;
     preAuthResource.response = response;
@@ -114,7 +114,7 @@ public class PreAuthResourceTest {
 
   @Test
   public void testPopulatingCustomActorIdHeaderNoGroups() throws Exception {
-    configureCommonExpectations("customActorId", null);
+    configureCommonExpectations("customActorId");
     final PreAuthResource preAuthResource = new PreAuthResource();
     preAuthResource.context = context;
     preAuthResource.response = response;
diff --git 
a/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/ConfigurableDispatch.java
 
b/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/ConfigurableDispatch.java
index d9a469783..08458e3cb 100644
--- 
a/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/ConfigurableDispatch.java
+++ 
b/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/ConfigurableDispatch.java
@@ -22,7 +22,9 @@ import org.apache.http.client.methods.HttpUriRequest;
 import org.apache.knox.gateway.audit.api.ActionOutcome;
 import org.apache.knox.gateway.config.Configure;
 import org.apache.knox.gateway.config.Default;
+import org.apache.knox.gateway.security.GroupPrincipal;
 import org.apache.knox.gateway.security.SubjectUtils;
+import org.apache.knox.gateway.util.GroupUtils;
 import org.apache.knox.gateway.util.StringUtils;
 
 import javax.security.auth.Subject;
@@ -40,9 +42,7 @@ import java.util.HashSet;
 import java.util.HashMap;
 import java.util.Optional;
 import java.util.List;
-import java.util.Collection;
 import java.util.Locale;
-import java.util.ArrayList;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -62,18 +62,20 @@ public class ConfigurableDispatch extends DefaultDispatch 
implements SyncDispatc
   private boolean shouldIncludePrincipalAndGroups;
   private String actorIdHeaderName = DEFAULT_AUTH_ACTOR_ID_HEADER_NAME;
   private String actorGroupsHeaderPrefix = 
DEFAULT_AUTH_ACTOR_GROUPS_HEADER_PREFIX;
+  private int groupHeaderLengthLimit = 
Integer.parseInt(DEFAULT_GROUP_HEADER_LENGTH_LIMIT);
+  private int groupHeaderSizeLimit = 
Integer.parseInt(DEFAULT_GROUP_HEADER_SIZE_LIMIT);
   private String groupFilterPattern = DEFAULT_GROUP_FILTER_PATTERN;
 
   static final String DEFAULT_AUTH_ACTOR_ID_HEADER_NAME = "X-Knox-Actor-ID";
   static final String DEFAULT_AUTH_ACTOR_GROUPS_HEADER_PREFIX = 
"X-Knox-Actor-Groups";
+  static final String DEFAULT_GROUP_HEADER_LENGTH_LIMIT = "1000";
+  static final String DEFAULT_GROUP_HEADER_SIZE_LIMIT = "-1"; // turned off by 
default, to be backward compatible
   static final String DEFAULT_GROUP_FILTER_PATTERN = ".*";
   static final String DEFAULT_ARE_USERS_GROUPS_HEADER_INCLUDED = "false";
 
-  protected static final int MAX_HEADER_LENGTH = 1000;
   protected static final String ACTOR_GROUPS_HEADER_FORMAT = "%s-%d";
   protected Pattern groupPattern = 
Pattern.compile(DEFAULT_GROUP_FILTER_PATTERN);
 
-
   private Set<String> convertCommaDelimitedHeadersToSet(String headers) {
     return headers == null ?  Collections.emptySet(): new 
HashSet<>(Arrays.asList(headers.split("\\s*,\\s*")));
   }
@@ -126,8 +128,8 @@ public class ConfigurableDispatch extends DefaultDispatch 
implements SyncDispatc
     if (setCookieHeader.isPresent()) {
       final String[] setCookieHeaderParts = setCookieHeader.get().split(":");
       responseExcludeSetCookieHeaderDirectives = setCookieHeaderParts.length > 
1
-          ? new 
HashSet<>(Arrays.asList(setCookieHeaderParts[1].split(";"))).stream().map(e -> 
e.trim()).collect(Collectors.toSet())
-          : EXCLUDE_SET_COOKIES_DEFAULT;
+              ? new 
HashSet<>(Arrays.asList(setCookieHeaderParts[1].split(";"))).stream().map(e -> 
e.trim()).collect(Collectors.toSet())
+              : EXCLUDE_SET_COOKIES_DEFAULT;
     } else {
       /* Exclude headers list is defined but we don't have set-cookie in the 
list,
       by default prevent these cookies from leaking */
@@ -162,6 +164,16 @@ public class ConfigurableDispatch extends DefaultDispatch 
implements SyncDispatc
     this.actorGroupsHeaderPrefix = actorGroupsHeaderPrefix;
   }
 
+  @Configure
+  public void 
setGroupHeaderLengthLimit(@Default(DEFAULT_GROUP_HEADER_LENGTH_LIMIT) int 
groupHeaderLengthLimit) {
+    this.groupHeaderLengthLimit = groupHeaderLengthLimit;
+  }
+
+  @Configure
+  public void 
setGroupHeaderSizeLimit(@Default(DEFAULT_GROUP_HEADER_SIZE_LIMIT) int 
groupHeaderSizeLimit) {
+    this.groupHeaderSizeLimit = groupHeaderSizeLimit;
+  }
+
   @Configure
   public void setGroupFilterPattern(@Default(DEFAULT_GROUP_FILTER_PATTERN) 
String groupFilterPattern) {
     this.groupFilterPattern = groupFilterPattern;
@@ -189,7 +201,7 @@ public class ConfigurableDispatch extends DefaultDispatch 
implements SyncDispatc
   }
 
   private Map<String, String> addPrincipalAndGroups() {
-    final Map<String, String> headers = new ConcurrentHashMap();
+    final Map<String, String> headers = new ConcurrentHashMap<>();
     final Subject subject = SubjectUtils.getCurrentSubject();
 
     final String primaryPrincipalName = subject == null ? null : 
SubjectUtils.getPrimaryPrincipalName(subject);
@@ -202,10 +214,12 @@ public class ConfigurableDispatch extends DefaultDispatch 
implements SyncDispatc
 
     // Populate actor groups headers
     final Set<String> matchingGroupNames = subject == null ? 
Collections.emptySet()
-            : SubjectUtils.getGroupPrincipals(subject).stream().filter(group 
-> groupPattern.matcher(group.getName()).matches()).map(group -> 
group.getName())
+            : SubjectUtils.getGroupPrincipals(subject).stream()
+            .filter(group -> groupPattern.matcher(group.getName()).matches())
+            .map(GroupPrincipal::getName)
             .collect(Collectors.toSet());
     if (!matchingGroupNames.isEmpty()) {
-      final List<String> groupStrings = getGroupStrings(matchingGroupNames);
+      final List<String> groupStrings = 
GroupUtils.getGroupStrings(matchingGroupNames, groupHeaderLengthLimit, 
groupHeaderSizeLimit);
       for (int i = 0; i < groupStrings.size(); i++) {
         headers.put(String.format(Locale.ROOT, ACTOR_GROUPS_HEADER_FORMAT, 
actorGroupsHeaderPrefix, i + 1), groupStrings.get(i));
       }
@@ -213,28 +227,6 @@ public class ConfigurableDispatch extends DefaultDispatch 
implements SyncDispatc
     return headers;
   }
 
-  private List<String> getGroupStrings(final Collection<String> groupNames) {
-    if (groupNames.isEmpty()) {
-      return Collections.emptyList();
-    }
-    List<String> groupStrings = new ArrayList<>();
-    StringBuilder sb = new StringBuilder();
-    for (String groupName : groupNames) {
-      if (sb.length() + groupName.length() > MAX_HEADER_LENGTH) {
-        groupStrings.add(sb.toString());
-        sb = new StringBuilder();
-      }
-      if (sb.length() > 0) {
-        sb.append(',');
-      }
-      sb.append(groupName);
-    }
-    if (sb.length() > 0) {
-      groupStrings.add(sb.toString());
-    }
-    return groupStrings;
-  }
-
   @Override
   public Set<String> getOutboundResponseExcludeHeaders() {
     return responseExcludeHeaders == null ? Collections.emptySet() : 
responseExcludeHeaders;
diff --git 
a/gateway-spi/src/main/java/org/apache/knox/gateway/util/GroupUtils.java 
b/gateway-spi/src/main/java/org/apache/knox/gateway/util/GroupUtils.java
new file mode 100644
index 000000000..db2d28277
--- /dev/null
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/util/GroupUtils.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.util;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+public class GroupUtils {
+
+    public static List<String> getGroupStrings(Collection<String> groupNames, 
final int lengthLimit, final int sizeLimitBytes) {
+        if (groupNames.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        // Defensive copy to isolate from concurrent modifications
+        final List<String> safeGroupNames = new ArrayList<>(groupNames);
+
+        if (sizeLimitBytes > 0) {
+            return getGroupStringsBySize(safeGroupNames, sizeLimitBytes);
+        } else {
+            return getGroupStringsByLength(safeGroupNames, lengthLimit);
+        }
+    }
+
+    private static List<String> getGroupStringsBySize(Collection<String> 
groupNames, int sizeLimitBytes) {
+        final List<String> groupStrings = new ArrayList<>();
+        StringBuilder sb = new StringBuilder();
+        int currentSize = 0; // UTF-8 byte count
+
+        for (String groupName : groupNames) {
+            int commaBytes = sb.length() > 0 ? 1 : 0; // comma between groups
+            int groupBytes = groupName.getBytes(StandardCharsets.UTF_8).length;
+            int projectedSize = currentSize + commaBytes + groupBytes;
+
+            if (projectedSize > sizeLimitBytes) {
+                saveGroups(groupStrings, sb);
+                sb = new StringBuilder();
+                currentSize = 0;
+            }
+
+            currentSize = addCommaIfNeeded(sb, currentSize);
+            sb.append(groupName);
+            currentSize += groupBytes;
+        }
+
+        if (sb.length() > 0) {
+            saveGroups(groupStrings, sb);
+        }
+
+        return groupStrings;
+    }
+
+    private static void saveGroups(final List<String> groupStrings, final 
StringBuilder sb) {
+        final String groups = sb.toString();
+        if (StringUtils.isNotBlank(groups)) {
+            groupStrings.add(groups);
+        }
+    }
+
+    private static int addCommaIfNeeded(final StringBuilder sb, int 
currentSize) {
+        if (sb.length() > 0) {
+            sb.append(',');
+            return currentSize + 1; // comma byte
+        } else {
+            return currentSize;
+        }
+    }
+
+    private static List<String> getGroupStringsByLength(Collection<String> 
groupNames, int lengthLimit) {
+        final List<String> groupStrings = new ArrayList<>();
+        StringBuilder sb = new StringBuilder();
+
+        for (String groupName : groupNames) {
+            int commaChars = sb.length() > 0 ? 1 : 0;
+            if (sb.length() + groupName.length() + commaChars > lengthLimit) {
+                saveGroups(groupStrings, sb);
+                sb = new StringBuilder();
+            }
+            addCommaIfNeeded(sb, 0);
+            sb.append(groupName);
+        }
+
+        if (sb.length() > 0) {
+            saveGroups(groupStrings, sb);
+        }
+
+        return groupStrings;
+    }
+}
diff --git 
a/gateway-spi/src/test/java/org/apache/knox/gateway/util/GroupUtilsTest.java 
b/gateway-spi/src/test/java/org/apache/knox/gateway/util/GroupUtilsTest.java
new file mode 100644
index 000000000..6314884f6
--- /dev/null
+++ b/gateway-spi/src/test/java/org/apache/knox/gateway/util/GroupUtilsTest.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.util;
+
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+
+public class GroupUtilsTest {
+
+    @Test
+    public void testSplitByLengthLimitOnly() {
+        final List<String> input = Arrays.asList("alpha", "beta", "gamma", 
"delta", "epsilon");
+        final List<String> result = GroupUtils.getGroupStrings(input, 10, -1); 
// size limit disabled
+
+        // Each chunk should respect the length limit (≤ 10 chars, including 
commas)
+        assertTrue(result.stream().allMatch(s -> s.length() <= 10));
+        assertEquals(Arrays.asList("alpha,beta", "gamma", "delta", "epsilon"), 
result);
+    }
+
+    @Test
+    public void testSplitBySizeLimitOnly() {
+        // We'll create UTF-8 multibyte strings to show that length != size
+        final String wideChar = "á"; // 1 char = 2 bytes in UTF-8
+        String group = String.join("", Collections.nCopies(2000, wideChar));
+        final List<String> input = Arrays.asList(group, group, group);
+
+        // 4KB = 4096 bytes; since each string 4000 bytes, only one fits per 
chunk
+        final List<String> result = GroupUtils.getGroupStrings(input, 10, 
4096);
+
+        // Should have 3 chunks because size limit applies, not length
+        assertEquals(3, result.size());
+
+        // Each chunk can exceed the length limit (since length = 2000 > 10)
+        assertTrue(result.stream().allMatch(s -> s.length() > 10));
+
+        // Each chunk must be ≤ 4KB in bytes
+        for (String s : result) {
+            int bytes = s.getBytes(StandardCharsets.UTF_8).length;
+            assertTrue("Chunk exceeds 4KB: " + bytes, bytes <= 4096);
+        }
+    }
+
+    @Test
+    public void testSplitBySizeLimitWithManySmallElements() throws Exception {
+        // 1. Create many small ASCII elements (5 bytes each + commas)
+        final List<String> input = new ArrayList<>();
+        for (int i = 1; i <= 100; i++) {
+            input.add("item" + i); // e.g., "item1", "item2", ...
+        }
+
+        final int sizeLimitBytes = 512;
+        final List<String> result = GroupUtils.getGroupStrings(input, 1000, 
sizeLimitBytes);
+
+        assertEquals("Should be chunked into multiple groups", 2, 
result.size());
+        assertEquals(508, 
result.get(0).getBytes(StandardCharsets.UTF_8).length);
+        assertEquals(182, 
result.get(1).getBytes(StandardCharsets.UTF_8).length);
+
+        // Verify all data preserved and ordered
+        final String joined = String.join(",", result);
+        final List<String> reconstructed = Arrays.asList(joined.split(","));
+        assertEquals("Item count should remain the same", input.size(), 
reconstructed.size());
+        assertEquals("All items should appear in the same order", input, 
reconstructed);
+    }
+
+    @Test
+    public void testSizeLimitTakesPrecedenceOverLengthLimit() {
+        // Use strings that easily exceed 10 chars but stay within 4KB
+        final List<String> input = Arrays.asList("abcdefghijklmno", 
"pqrstuvwxyz", "1234567890");
+
+        // lengthLimit=10 would normally split them all apart, but 
sizeLimit=4096 means we should keep them together
+        final List<String> result = GroupUtils.getGroupStrings(input, 10, 
4096);
+
+        // Expect a single chunk (because size limit is large enough)
+        assertEquals("Should not split by length when sizeLimit > 0", 1, 
result.size());
+    }
+
+    @Test
+    public void testEmptyInputReturnsEmptyList() {
+        final List<String> result = 
GroupUtils.getGroupStrings(Collections.emptyList(), 1000, 4096);
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    public void testShouldNotReturnEmptyElements() {
+        final List<String> input = Arrays.asList("longGroupName1", 
"longGroupName2", "longGroupName3");
+        final List<String> result = GroupUtils.getGroupStrings(input, 10, 1); 
// note the size limit is set to 1
+        assertEquals("Should not return a list with empty elements", 3, 
result.size());
+    }
+
+    @Test
+    public void testConcurrentGroupStringCalls() throws Exception {
+        final int THREAD_COUNT = 20;
+        final int ITERATIONS = 1000;
+        final ExecutorService executor = 
Executors.newFixedThreadPool(THREAD_COUNT);
+        final List<Future<List<String>>> futures = new ArrayList<>();
+
+        final List<String> input = new ArrayList<>();
+        for (int i = 0; i < 100; i++) {
+            input.add("group_" + i);
+        }
+
+        for (int t = 0; t < THREAD_COUNT; t++) {
+            futures.add(executor.submit(() -> {
+                for (int i = 0; i < ITERATIONS; i++) {
+                    List<String> result = GroupUtils.getGroupStrings(input, 
50, 512);
+                    assertNotNull(result);
+                    assertFalse(result.isEmpty());
+
+                    Set<String> allGroups = new HashSet<>();
+                    for (String chunk : result) {
+                        int bytes = 
chunk.getBytes(StandardCharsets.UTF_8).length;
+                        assertTrue("Chunk exceeds 512 bytes: " + bytes, bytes 
<= 512);
+
+                        for (String g : chunk.split(",")) {
+                            assertTrue("Duplicate group found: " + g, 
allGroups.add(g));
+                        }
+                    }
+
+                    assertEquals("Missing or extra groups in result", 
input.size(), allGroups.size());
+                    assertTrue("Not all expected groups are present", 
allGroups.containsAll(input));
+                }
+                return null;
+            }));
+        }
+
+        for (Future<List<String>> f : futures) {
+            f.get(10, TimeUnit.SECONDS);
+        }
+
+        executor.shutdown();
+        assertTrue(executor.awaitTermination(3, TimeUnit.SECONDS));
+    }
+
+}

Reply via email to