From e4e71b9f087b06d670fa323d6b8cd9d0438e7078 Mon Sep 17 00:00:00 2001 From: Enrico Risa Date: Fri, 12 Apr 2024 14:44:49 +0200 Subject: [PATCH] feat: BitString set API (#4112) feat: modifiable + writer for bitstring --- gradle/libs.versions.toml | 1 + spi/common/core-spi/build.gradle.kts | 2 +- .../spi/model/statuslist/BitString.java | 105 +++++++++++- .../spi/model/statuslist/BitStringTest.java | 153 ++++++++++++++---- 4 files changed, 229 insertions(+), 32 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a2a5652afe..f316269858d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,6 +53,7 @@ h2 = { module = "com.h2database:h2", version.ref = "h2" } jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jackson-datatype-jakarta-jsonp = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jakarta-jsonp", version.ref = "jackson" } +jackson-datatypeJsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } jakarta-rsApi = { module = "jakarta.ws.rs:jakarta.ws.rs-api", version.ref = "rsApi" } jakarta-transaction-api = { module = "jakarta.transaction:jakarta.transaction-api", version.ref = "jakarta-transaction" } jakartaJson = { module = "org.glassfish:jakarta.json", version.ref = "jakarta-json" } diff --git a/spi/common/core-spi/build.gradle.kts b/spi/common/core-spi/build.gradle.kts index 22e658924fc..932c5a58c83 100644 --- a/spi/common/core-spi/build.gradle.kts +++ b/spi/common/core-spi/build.gradle.kts @@ -23,7 +23,7 @@ dependencies { api(libs.failsafe.core) api(project(":spi:common:boot-spi")) api(project(":spi:common:policy-model")) - + api(libs.jackson.datatypeJsr310) implementation(libs.opentelemetry.api) testImplementation(project(":tests:junit-base")); diff --git a/spi/common/verifiable-credentials-spi/src/main/java/org/eclipse/edc/iam/verifiablecredentials/spi/model/statuslist/BitString.java b/spi/common/verifiable-credentials-spi/src/main/java/org/eclipse/edc/iam/verifiablecredentials/spi/model/statuslist/BitString.java index 2283e760291..957ca29184e 100644 --- a/spi/common/verifiable-credentials-spi/src/main/java/org/eclipse/edc/iam/verifiablecredentials/spi/model/statuslist/BitString.java +++ b/spi/common/verifiable-credentials-spi/src/main/java/org/eclipse/edc/iam/verifiablecredentials/spi/model/statuslist/BitString.java @@ -21,6 +21,7 @@ import java.io.IOException; import java.util.Base64; import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; /** * Representation of StatusList2021Credential#bitstring @@ -51,15 +52,74 @@ public boolean get(int idx) { throw new IllegalArgumentException("Index out of range 0-%s".formatted(length())); } var byteIdx = idx / bitsPerByte; - var bitIdx = idx % bitsPerByte; - var shift = leftToRightIndexing ? (7 - bitIdx) : bitIdx; + var shift = bitPosition(idx); return (bits[byteIdx] & (1L << shift)) != 0; } + /** + * Set the bit at idx to either `1` or `0` depending on the boolean in input + * + * @param idx The index to change + * @param status true or false if it's revoked or not + */ + public void set(int idx, boolean status) { + if (idx < 0 || idx >= length()) { + throw new IllegalArgumentException("Index out of range 0-%s".formatted(length())); + } + var byteIdx = idx / bitsPerByte; + var shift = bitPosition(idx); + + if (status) { + bits[byteIdx] |= (byte) (1L << shift); + } else { + bits[byteIdx] &= (byte) ~(1L << shift); + } + } + public int length() { return bits.length * bitsPerByte; } + private int bitPosition(int idx) { + var bitIdx = idx % bitsPerByte; + return leftToRightIndexing ? (7 - bitIdx) : bitIdx; + } + + /** + * Parser configuration for {@link BitString} + */ + public static final class Builder { + + private boolean leftToRightIndexing = true; + private int size = 16 * 1024 * 8; + + private Builder() { + } + + public static Builder newInstance() { + return new Builder(); + } + + + public Builder leftToRightIndexing(boolean leftToRightIndexing) { + this.leftToRightIndexing = leftToRightIndexing; + return this; + } + + public Builder size(int size) { + this.size = size; + return this; + } + + public BitString build() { + if (size % 8 != 0) { + throw new IllegalArgumentException("BitString size should be multiple of 8"); + } + var bits = new byte[size / 8]; + return new BitString(bits, leftToRightIndexing); + } + } + /** * Parser configuration for {@link BitString} */ @@ -86,11 +146,11 @@ public Parser decoder(Base64.Decoder decoder) { public Result parse(String encodedList) { return Result.ofThrowable(() -> decoder.decode(encodedList)) - .compose(this::unGzip) + .compose(this::decompress) .map(bytes -> new BitString(bytes, leftToRightIndexing)); } - private Result unGzip(byte[] bytes) { + private Result decompress(byte[] bytes) { try (var inputStream = new GZIPInputStream(new ByteArrayInputStream(bytes))) { try (var outputStream = new ByteArrayOutputStream()) { inputStream.transferTo(outputStream); @@ -101,4 +161,41 @@ private Result unGzip(byte[] bytes) { } } } + + /** + * Writer configuration for {@link BitString} + */ + public static final class Writer { + private Base64.Encoder encoder = Base64.getEncoder(); + + private Writer() { + } + + public static Writer newInstance() { + return new Writer(); + } + + public Writer encoder(Base64.Encoder encoder) { + this.encoder = encoder; + return this; + } + + public Result write(BitString bitString) { + return compress(bitString.bits) + .compose(compressed -> Result.ofThrowable(() -> encoder.encodeToString(compressed))); + + } + + private Result compress(byte[] bytes) { + try (var outputStream = new ByteArrayOutputStream()) { + try (var zipStream = new GZIPOutputStream(outputStream)) { + zipStream.write(bytes); + zipStream.close(); + return Result.success(outputStream.toByteArray()); + } + } catch (IOException e) { + return Result.failure("Failed to gzip the input bytes: %s".formatted(e.getMessage())); + } + } + } } diff --git a/spi/common/verifiable-credentials-spi/src/test/java/org/eclipse/edc/iam/verifiablecredentials/spi/model/statuslist/BitStringTest.java b/spi/common/verifiable-credentials-spi/src/test/java/org/eclipse/edc/iam/verifiablecredentials/spi/model/statuslist/BitStringTest.java index d7ea1ab467b..fa8ee78e4af 100644 --- a/spi/common/verifiable-credentials-spi/src/test/java/org/eclipse/edc/iam/verifiablecredentials/spi/model/statuslist/BitStringTest.java +++ b/spi/common/verifiable-credentials-spi/src/test/java/org/eclipse/edc/iam/verifiablecredentials/spi/model/statuslist/BitStringTest.java @@ -14,6 +14,7 @@ package org.eclipse.edc.iam.verifiablecredentials.spi.model.statuslist; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.params.ParameterizedTest; @@ -21,51 +22,80 @@ import org.junit.jupiter.params.provider.ArgumentsProvider; import org.junit.jupiter.params.provider.ArgumentsSource; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.util.Arrays; import java.util.Base64; import java.util.stream.Stream; +import java.util.zip.GZIPInputStream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; public class BitStringTest { - @ParameterizedTest - @ArgumentsSource(ValidEncodedListProvider.class) - void parse(String list, int size, int[] revoked, Base64.Decoder decoder, boolean leftToRightIndexing) { + private static Base64.Decoder getDecoder(Format format) { + return switch (format) { + case Base64 -> Base64.getDecoder(); + case Base64Url -> Base64.getUrlDecoder(); + }; + } - var result = BitString.Parser.newInstance().decoder(decoder).leftToRightIndexing(leftToRightIndexing).parse(list); + private static Base64.Encoder getEncoder(Format format) { + return switch (format) { + case Base64 -> Base64.getEncoder(); + case Base64Url -> Base64.getUrlEncoder(); + }; + } - assertThat(result.succeeded()).isTrue(); - assertThat(result.getContent()).satisfies(bitString -> { - assertThat(bitString.length()).isEqualTo(size); - Arrays.stream(revoked).forEach((idx) -> assertThat(bitString.get(idx)).isTrue()); - }); + @Test + void get() { + + var bitString = BitString.Builder.newInstance().build(); + + assertThat(bitString.get(0)).isFalse(); + assertThat(bitString.get(50_000)).isFalse(); + + bitString.set(0, true); + bitString.set(50_000, true); + + assertThat(bitString.get(0)).isTrue(); + assertThat(bitString.get(50_000)).isTrue(); + + bitString.set(0, false); + bitString.set(50_000, false); + + assertThat(bitString.get(0)).isFalse(); + assertThat(bitString.get(50_000)).isFalse(); } @Test void get_whenOutOfBound() { - var bitString = BitString.Parser.newInstance().parse("H4sIAAAAAAAAA+3BMQEAAADCoPVPbQsvoAAAAAAAAAAAAAAAAP4GcwM92tQwAAA=").getContent(); + var bitString = BitString.Builder.newInstance().build(); assertThatThrownBy(() -> bitString.get(200_000)).isInstanceOf(IllegalArgumentException.class); assertThatThrownBy(() -> bitString.get(-10)).isInstanceOf(IllegalArgumentException.class); } @Test - void parse_invalidBas64() { + void set_whenOutOfBound() { + + var bitString = BitString.Builder.newInstance().build(); - var result = BitString.Parser.newInstance().parse("invalid-"); - assertThat(result.failed()).isTrue(); - assertThat(result.getFailureDetail()).contains("Illegal base64 character"); + assertThatThrownBy(() -> bitString.set(200_000, true)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> bitString.set(-10, true)).isInstanceOf(IllegalArgumentException.class); } @Test - void parse_invalidGzip() { + void build_invalidSize() { + assertThatThrownBy(() -> BitString.Builder.newInstance().size(10).build()).isInstanceOf(IllegalArgumentException.class); + } - var result = BitString.Parser.newInstance().parse("invalid/gzip"); - assertThat(result.failed()).isTrue(); - assertThat(result.getFailureDetail()).contains("Failed to ungzip encoded list: Not in GZIP format"); + enum Format { + Base64, + Base64Url } private static class ValidEncodedListProvider implements ArgumentsProvider { @@ -74,19 +104,88 @@ private static class ValidEncodedListProvider implements ArgumentsProvider { public Stream provideArguments(ExtensionContext context) { return Stream.of( // Base64 decoder - Arguments.of("H4sIAAAAAAAAA+3BMQEAAADCoPVPbQsvoAAAAAAAAAAAAAAAAP4GcwM92tQwAAA=", 100_000, new int[]{}, Base64.getDecoder(), true), - Arguments.of("H4sIAAAAAAAAA+3BIQEAAAACIP+vcKozLEADAAAAAAAAAAAAAAAAAAAAvA0cOP65AEAAAA", 131072, new int[]{ 0, 2 }, Base64.getDecoder(), true), - Arguments.of("H4sIAAAAAAAAA+3OMQ0AAAgDsElHOh72EJJWQRMAAAAAAIDWXAcAAAAAAIDHFvRitn7UMAAA", 100_000, new int[]{ 50_000 }, Base64.getDecoder(), true), - Arguments.of("H4sIAAAAAAAAA+3BIQEAAAACIP1/2hkWoAEAAAAAAAAAAAAAAAAAAADeBjn7xTYAQAAA", 131072, new int[]{ 7 }, Base64.getDecoder(), true), + Arguments.of("H4sIAAAAAAAAA+3BMQEAAADCoPVPbQsvoAAAAAAAAAAAAAAAAP4GcwM92tQwAAA=", 100_000, new int[]{}, Format.Base64, true), + Arguments.of("H4sIAAAAAAAAA+3BIQEAAAACIP+vcKozLEADAAAAAAAAAAAAAAAAAAAAvA0cOP65AEAAAA", 131072, new int[]{ 0, 2 }, Format.Base64, true), + Arguments.of("H4sIAAAAAAAAA+3OMQ0AAAgDsElHOh72EJJWQRMAAAAAAIDWXAcAAAAAAIDHFvRitn7UMAAA", 100_000, new int[]{ 50_000 }, Format.Base64, true), + Arguments.of("H4sIAAAAAAAAA+3BIQEAAAACIP1/2hkWoAEAAAAAAAAAAAAAAAAAAADeBjn7xTYAQAAA", 131072, new int[]{ 7 }, Format.Base64, true), // Base64 URL decoder - Arguments.of("H4sIAAAAAAAAA-3BMQEAAADCoPVPbQsvoAAAAAAAAAAAAAAAAP4GcwM92tQwAAA", 100_000, new int[]{}, Base64.getUrlDecoder(), true), - Arguments.of("H4sIAAAAAAAAA-3BIQEAAAACIP-vcKozLEADAAAAAAAAAAAAAAAAAAAAvA0cOP65AEAAAA", 131072, new int[]{ 0, 2 }, Base64.getUrlDecoder(), true), - Arguments.of("H4sIAAAAAAAAA-3OMQ0AAAgDsElHOh72EJJWQRMAAAAAAIDWXAcAAAAAAIDHFvRitn7UMAAA", 100_000, new int[]{ 50_000 }, Base64.getUrlDecoder(), true), - Arguments.of("H4sIAAAAAAAAA-3BIQEAAAACIP1_2hkWoAEAAAAAAAAAAAAAAAAAAADeBjn7xTYAQAAA", 131072, new int[]{ 7 }, Base64.getUrlDecoder(), true), + Arguments.of("H4sIAAAAAAAAA-3BMQEAAADCoPVPbQsvoAAAAAAAAAAAAAAAAP4GcwM92tQwAAA", 100_000, new int[]{}, Format.Base64Url, true), + Arguments.of("H4sIAAAAAAAAA-3BIQEAAAACIP-vcKozLEADAAAAAAAAAAAAAAAAAAAAvA0cOP65AEAAAA", 131072, new int[]{ 0, 2 }, Format.Base64Url, true), + Arguments.of("H4sIAAAAAAAAA-3OMQ0AAAgDsElHOh72EJJWQRMAAAAAAIDWXAcAAAAAAIDHFvRitn7UMAAA", 100_000, new int[]{ 50_000 }, Format.Base64Url, true), + Arguments.of("H4sIAAAAAAAAA-3BIQEAAAACIP1_2hkWoAEAAAAAAAAAAAAAAAAAAADeBjn7xTYAQAAA", 131072, new int[]{ 7 }, Format.Base64Url, true), // Left to right = false - Arguments.of("H4sIAAAAAAAA_-3AIQEAAAACIIv_LzvDAg0AAAAAAAAAAAAAAAAAAADwNgZXEi0AQAAA", 131072, new int[]{ 0, 2 }, Base64.getUrlDecoder(), false) + Arguments.of("H4sIAAAAAAAA_-3AIQEAAAACIIv_LzvDAg0AAAAAAAAAAAAAAAAAAADwNgZXEi0AQAAA", 131072, new int[]{ 0, 2 }, Format.Base64Url, false) ); } } + + @Nested + class Parse { + + @ParameterizedTest + @ArgumentsSource(ValidEncodedListProvider.class) + void parse(String list, int size, int[] revoked, Format format, boolean leftToRightIndexing) { + + var result = BitString.Parser.newInstance().decoder(getDecoder(format)).leftToRightIndexing(leftToRightIndexing).parse(list); + + assertThat(result.succeeded()).isTrue(); + assertThat(result.getContent()).satisfies(bitString -> { + assertThat(bitString.length()).isEqualTo(size); + Arrays.stream(revoked).forEach((idx) -> assertThat(bitString.get(idx)).isTrue()); + }); + } + + @Test + void parse_invalidBas64() { + + var result = BitString.Parser.newInstance().parse("invalid-"); + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Illegal base64 character"); + } + + @Test + void parse_invalidGzip() { + + var result = BitString.Parser.newInstance().parse("invalid/gzip"); + assertThat(result.failed()).isTrue(); + assertThat(result.getFailureDetail()).contains("Failed to ungzip encoded list: Not in GZIP format"); + } + } + + @Nested + class Write { + + @ParameterizedTest + @ArgumentsSource(ValidEncodedListProvider.class) + void write(String list, int size, int[] revoked, Format format, boolean leftToRightIndexing) { + + var bitString = BitString.Builder.newInstance().size(size).leftToRightIndexing(leftToRightIndexing).build(); + assertThat(bitString.length()).isEqualTo(size); + + Arrays.stream(revoked).forEach((idx) -> bitString.set(idx, true)); + Arrays.stream(revoked).forEach((idx) -> assertThat(bitString.get(idx)).isTrue()); + + var result = BitString.Writer.newInstance().encoder(getEncoder(format)).write(bitString); + + var decoder = getDecoder(format); + assertThat(result.succeeded()).isTrue(); + assertThat(decode(list, decoder)).isEqualTo(decode(result.getContent(), decoder)); + } + + private byte[] decode(String list, Base64.Decoder decoder) { + return decompress(decoder.decode(list)); + } + + private byte[] decompress(byte[] bytes) { + try (var inputStream = new GZIPInputStream(new ByteArrayInputStream(bytes))) { + try (var outputStream = new ByteArrayOutputStream()) { + inputStream.transferTo(outputStream); + return outputStream.toByteArray(); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } }