Skip to content

Bugfix: Nested collection replacment #970

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 21 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b83bd89
Implement DefaultCollectionMutator.replace(E e)
lhy-hoyin Oct 16, 2024
508227c
tests: Add tests for DefaultCollectionMutator.replace(E e)
lhy-hoyin Oct 16, 2024
01003bf
Implement overloaded Assert.notEmpty for asserting Object
lhy-hoyin Oct 17, 2024
da96e8b
Change method signature of replace
lhy-hoyin Oct 19, 2024
8e8fd6c
Implement DefaultMacAlgorithm.equals
lhy-hoyin Oct 19, 2024
fd8dc5e
Modify add(e) logic to replace existing with same ID
lhy-hoyin Oct 19, 2024
0c15467
Modify tests to explicitly test for situation mentioned in #961
lhy-hoyin Oct 19, 2024
57e4268
fix: Fix issue where replace does not do a in-place replacement
lhy-hoyin Oct 19, 2024
a480d1e
Integrate replacement logic directly into add
lhy-hoyin Oct 29, 2024
f973a3d
Changing JDK 17 build to use GitHub's 'setup-java' instead of Oracle'…
lhazlewood Mar 13, 2025
053855f
Use SPDX identifier in POMs (#979)
TheMrMilchmann Mar 13, 2025
4beef3a
Implement DefaultCollectionMutator.replace(E e)
lhy-hoyin Oct 16, 2024
3e407f6
tests: Add tests for DefaultCollectionMutator.replace(E e)
lhy-hoyin Oct 16, 2024
de9f3d9
Implement overloaded Assert.notEmpty for asserting Object
lhy-hoyin Oct 17, 2024
db865ee
Change method signature of replace
lhy-hoyin Oct 19, 2024
d0bad35
Implement DefaultMacAlgorithm.equals
lhy-hoyin Oct 19, 2024
6d7a100
Modify add(e) logic to replace existing with same ID
lhy-hoyin Oct 19, 2024
8e36dce
Modify tests to explicitly test for situation mentioned in #961
lhy-hoyin Oct 19, 2024
8ab77ec
fix: Fix issue where replace does not do a in-place replacement
lhy-hoyin Oct 19, 2024
d50700f
Integrate replacement logic directly into add
lhy-hoyin Oct 29, 2024
484872b
Merge branch 'nested-collection-replace' of https://github.com/lhy-ho…
lhazlewood Mar 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: oracle-actions/setup-java@v1
uses: actions/setup-java@v4.7.0
with:
release: ${{ matrix.java }}
distribution: oracle
java-version: ${{ matrix.java }}
- name: Install softhsm2
run: sudo apt-get install -y softhsm2
- name: Install opensc
Expand Down
27 changes: 27 additions & 0 deletions api/src/main/java/io/jsonwebtoken/lang/Assert.java
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,33 @@ public static void notEmpty(Map map) {
notEmpty(map, "[Assertion failed] - this map must not be empty; it must contain at least one entry");
}

/**
* Assert that an Object is not <code>null</code> or empty.
* <pre class="code">Assert.notEmpty(object, "Object cannot be null or empty");</pre>
*
* @param object the object to check
* @param message the exception message to use if the assertion fails
* @return the non-null, non-empty object
* @throws IllegalArgumentException if the object is <code>null</code> or empty
*/
public static Object notEmpty(Object object, String message) {
if (Objects.isEmpty(object)) {
throw new IllegalArgumentException(message);
}
return object;
}

/**
* Assert that an Object is not <code>null</code> or empty.
* <pre class="code">Assert.notEmpty(object);</pre>
*
* @param object the object to check
* @throws IllegalArgumentException if the object is <code>null</code> or empty
*/
public static void notEmpty(Object object) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just did a code search, and this method isn't used anywhere, so it's best to remove it, as well as the newly added notEmpty(Object, String) method above.

notEmpty(object, "[Assertion failed] - this object must not be null or empty");
}


/**
* Assert that the provided object is an instance of the provided class.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import io.jsonwebtoken.lang.Strings;

import java.util.Collection;
import java.util.Iterator;
import java.util.LinkedHashSet;

public class DefaultCollectionMutator<E, M extends CollectionMutator<E, M>> implements CollectionMutator<E, M> {
Expand All @@ -48,7 +49,45 @@ private boolean doAdd(E e) {

@Override
public M add(E e) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a fairly complex implementation - it almost makes me think we should create a specific abstract subclass for Algorithm/ID-specific logic, e.g. AlgorithmCollectionMutator or something like that.

Such a subclass could also be more efficient, potentially using a Set<String> or a Set<String,Identifiable> to keep track of what IDs exist already instead of iterating over the internal collection.

Finally, there's no method contract that collection element ordering will remain unchanged, so we don't need to perform step 3 - that will implicitly create a performance penalty by saving trailing elements and adding them back in to the end of the collection after replacing the found element.

Thoughts?

if (doAdd(e)) changed();
boolean doReplace = false;

// Replacement step 1: iterate until element to replace (if any)
E item;
Iterator<E> it = this.collection.iterator();
while (it.hasNext()) {
item = it.next();

// Same item, nothing to do
if (item.equals(e))
return self();

boolean bothIdentifiable = e instanceof Identifiable && item instanceof Identifiable;
boolean sameId = bothIdentifiable && ((Identifiable) item).getId().equals(((Identifiable) e).getId());
if (sameId) {
it.remove(); // step 2: remove existing item
doReplace = true;
break;
}
}

if (doReplace) {
// Replacement step 3: collect and remove elements after element to replace
Collection<E> elementsAfterExisting = new LinkedHashSet<>();
while (it.hasNext()) {
elementsAfterExisting.add(it.next());
it.remove();
}

this.doAdd(e); // step 4: add replacer element (position will be at the existing item)
this.collection.addAll(elementsAfterExisting); // step 5: add back the elements found after existing item

changed(); // trigger changed()
}
else {
// No replacement, do add instead
if (doAdd(e)) changed();
}

return self();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,16 @@ protected boolean doVerify(VerifySecureDigestRequest<SecretKey> request) {
byte[] computedSignature = digest(request);
return MessageDigest.isEqual(providedSignature, computedSignature);
}

@Override
public boolean equals(Object obj) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ordinarily, I agree that sub-type checking with specific properties should be performed in sub-classes, but in this case, all crypto JWT algorithms are guaranteed to be uniquely defined by their ID (and JCA name), regardless of internal implementation details. (In this particular case, the minKeyBitLength is really an internal convenience and not needed for algorithm equality check).

Additionally, if we changed DefaultMacAlgorithm, we'd have to change every other subclass of the parent CryptoAlgorithm and that could have far reaching consequences.

Based on this, we don't want to override equals at all, so please remove this method.

if (this == obj) {
return true;
}
if (obj instanceof DefaultMacAlgorithm) {
DefaultMacAlgorithm other = (DefaultMacAlgorithm) obj;
return super.equals(obj) && this.minKeyBitLength == other.minKeyBitLength;
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@
package io.jsonwebtoken.impl.lang

import io.jsonwebtoken.Identifiable
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.lang.Strings
import io.jsonwebtoken.security.MacAlgorithm
import org.junit.Before
import org.junit.Test

import java.lang.reflect.Constructor

import static org.junit.Assert.*

/**
Expand Down Expand Up @@ -48,9 +52,17 @@ class DefaultCollectionMutatorTest {
assertTrue c.isEmpty()
}

@Test
void addNull() {
m.add(null)
assertEquals 0, changeCount
assertTrue m.getCollection().isEmpty() // wasn't added
}

@Test
void addEmpty() {
m.add(Strings.EMPTY)
assertEquals 0, changeCount
assertTrue m.getCollection().isEmpty() // wasn't added
}

Expand Down Expand Up @@ -95,24 +107,48 @@ class DefaultCollectionMutatorTest {

@Test(expected = IllegalArgumentException)
void addIdentifiableWithNullId() {
def e = new Identifiable() {
@Override
String getId() {
return null
}
}
m.add(e)
m.add(new IdentifiableObject(null, null))
}

@Test(expected = IllegalArgumentException)
void addIdentifiableWithEmptyId() {
def e = new Identifiable() {
@Override
String getId() {
return ' '
}
}
m.add(e)
m.add(new IdentifiableObject(' ', null))
}

@Test
void addIdentifiableWithSameIdEvictsExisting() {
m.add(new IdentifiableObject('sameId', 'foo'))
m.add(new IdentifiableObject('sameId', 'bar'))
assertEquals 2, changeCount
assertEquals 1, m.collection.size() // second 'add' should evict first
assertEquals 'bar', ((IdentifiableObject) m.collection.toArray()[0]).obj
}

@Test
void addIdentifiableWithSameIdMaintainsOrder() {
IdentifiableObject e1 = new IdentifiableObject('1', 'e1')
IdentifiableObject e2 = new IdentifiableObject('sameId', 'e2')
IdentifiableObject e3 = new IdentifiableObject('3', 'e3')
IdentifiableObject eB = new IdentifiableObject('sameId', 'eB')

m.add([e1, e2, e3])
m.add(eB) // replace e2 with eB
assertEquals 2, changeCount
assertArrayEquals(new Object[] {e1, eB, e3}, m.collection.toArray())
}

@Test
void addSecureDigestAlgorithmWithSameIdReplacesExisting() {
Class<?> c = Class.forName("io.jsonwebtoken.impl.security.DefaultMacAlgorithm")
Constructor<?> ctor = c.getDeclaredConstructor(String.class, String.class, int.class)
ctor.setAccessible(true)
MacAlgorithm custom = (MacAlgorithm) ctor.newInstance('HS512', 'HmacSHA512', 80)

m.add(Jwts.SIG.HS512)
m.add(custom)
assertEquals 2, changeCount // replace is count as one change
assertEquals 1, m.getCollection().size() // existing is removed as part of replacement
assertEquals 80, ((MacAlgorithm) m.getCollection().toArray()[0]).getKeyBitLength()
}

@Test
Expand All @@ -135,4 +171,19 @@ class DefaultCollectionMutatorTest {
m.clear()
assertTrue m.getCollection().isEmpty()
}

private class IdentifiableObject implements Identifiable {
String id
Object obj

IdentifiableObject(String id, Object obj) {
this.id = id
this.obj = obj
}

@Override
String getId() {
return id
}
}
}
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

<licenses>
<license>
<name>Apache License, Version 2.0</name>
<name>Apache-2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
Expand Down
Loading