Skip to content

Commit

Permalink
feat: BitString set API (eclipse-edc#4112)
Browse files Browse the repository at this point in the history
feat: modifiable + writer for bitstring
  • Loading branch information
wolf4ood authored Apr 12, 2024
1 parent db0467b commit e4e71b9
Show file tree
Hide file tree
Showing 4 changed files with 229 additions and 32 deletions.
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion spi/common/core-spi/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import java.io.IOException;
import java.util.Base64;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

/**
* Representation of <a href="https://www.w3.org/TR/2023/WD-vc-status-list-20230427/#bitstring-encoding">StatusList2021Credential#bitstring</a>
Expand Down Expand Up @@ -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}
*/
Expand All @@ -86,11 +146,11 @@ public Parser decoder(Base64.Decoder decoder) {

public Result<BitString> parse(String encodedList) {
return Result.ofThrowable(() -> decoder.decode(encodedList))
.compose(this::unGzip)
.compose(this::decompress)
.map(bytes -> new BitString(bytes, leftToRightIndexing));
}

private Result<byte[]> unGzip(byte[] bytes) {
private Result<byte[]> decompress(byte[] bytes) {
try (var inputStream = new GZIPInputStream(new ByteArrayInputStream(bytes))) {
try (var outputStream = new ByteArrayOutputStream()) {
inputStream.transferTo(outputStream);
Expand All @@ -101,4 +161,41 @@ private Result<byte[]> 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<String> write(BitString bitString) {
return compress(bitString.bits)
.compose(compressed -> Result.ofThrowable(() -> encoder.encodeToString(compressed)));

}

private Result<byte[]> 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()));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,58 +14,88 @@

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;
import org.junit.jupiter.params.provider.Arguments;
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 {
Expand All @@ -74,19 +104,88 @@ private static class ValidEncodedListProvider implements ArgumentsProvider {
public Stream<? extends Arguments> 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);
}
}
}
}

0 comments on commit e4e71b9

Please sign in to comment.