From f8c88476d92bae7bb5ff049aebbfaa9f14f29861 Mon Sep 17 00:00:00 2001 From: Chad Huff Date: Tue, 9 Jun 2026 07:13:10 -0600 Subject: [PATCH 1/4] fix: use Fibonacci OptimizedRange for TcfCaV1 vendor fields The Canada (TcfCaV1) VendorExpressConsent, VendorImpliedConsent, and DisclosedVendors fields are OptimizedRange per the spec, which is Fibonacci-coded in GPP. They were using EncodableOptimizedFixedRange (the legacy OptimizedIntRange used by TCF EU); switch them to the existing EncodableOptimizedFibonacciRange. Regenerate the affected test vectors (only populated-vendor cases change; empty/bitfield cases are encoder-agnostic) and add a round-trip test that exercises the Fibonacci range path with sparse vendor IDs. mvn test: 365 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 --- .../encoder/segment/TcfCaV1CoreSegment.java | 6 +++--- .../TcfCaV1DisclosedVendorsSegment.java | 4 ++-- .../com/iab/gpp/encoder/GppModelTest.java | 4 ++-- .../iab/gpp/encoder/section/TcfCaV1Test.java | 19 +++++++++++++++++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1CoreSegment.java b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1CoreSegment.java index fbdc9479..7e63b265 100644 --- a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1CoreSegment.java +++ b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1CoreSegment.java @@ -13,7 +13,7 @@ import com.iab.gpp.encoder.datatype.EncodableFixedBitfield; import com.iab.gpp.encoder.datatype.EncodableFixedInteger; import com.iab.gpp.encoder.datatype.EncodableFixedString; -import com.iab.gpp.encoder.datatype.EncodableOptimizedFixedRange; +import com.iab.gpp.encoder.datatype.EncodableOptimizedFibonacciRange; import com.iab.gpp.encoder.error.DecodingException; import com.iab.gpp.encoder.field.EncodableBitStringFields; import com.iab.gpp.encoder.field.TcfCaV1Field; @@ -61,8 +61,8 @@ protected EncodableBitStringFields initializeFields() { fields.put(TcfCaV1Field.PURPOSES_IMPLIED_CONSENT, new EncodableFixedBitfield(Arrays.asList(false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false))); - fields.put(TcfCaV1Field.VENDOR_EXPRESS_CONSENT, new EncodableOptimizedFixedRange(new ArrayList<>())); - fields.put(TcfCaV1Field.VENDOR_IMPLIED_CONSENT, new EncodableOptimizedFixedRange(new ArrayList<>())); + fields.put(TcfCaV1Field.VENDOR_EXPRESS_CONSENT, new EncodableOptimizedFibonacciRange(new ArrayList<>())); + fields.put(TcfCaV1Field.VENDOR_IMPLIED_CONSENT, new EncodableOptimizedFibonacciRange(new ArrayList<>())); fields.put(TcfCaV1Field.PUB_RESTRICTIONS, new EncodableArrayOfFixedIntegerRanges(6, 2, new ArrayList<>(), false)); return fields; } diff --git a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1DisclosedVendorsSegment.java b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1DisclosedVendorsSegment.java index cbd7e90f..3afc3516 100644 --- a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1DisclosedVendorsSegment.java +++ b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1DisclosedVendorsSegment.java @@ -6,7 +6,7 @@ import com.iab.gpp.encoder.base64.CompressedBase64UrlEncoder; import com.iab.gpp.encoder.bitstring.BitStringEncoder; import com.iab.gpp.encoder.datatype.EncodableFixedInteger; -import com.iab.gpp.encoder.datatype.EncodableOptimizedFixedRange; +import com.iab.gpp.encoder.datatype.EncodableOptimizedFibonacciRange; import com.iab.gpp.encoder.error.DecodingException; import com.iab.gpp.encoder.field.EncodableBitStringFields; import com.iab.gpp.encoder.field.TcfCaV1Field; @@ -34,7 +34,7 @@ public List getFieldNames() { protected EncodableBitStringFields initializeFields() { EncodableBitStringFields fields = new EncodableBitStringFields(); fields.put(TcfCaV1Field.DISCLOSED_VENDORS_SEGMENT_TYPE, new EncodableFixedInteger(3, 1)); - fields.put(TcfCaV1Field.DISCLOSED_VENDORS, new EncodableOptimizedFixedRange(new ArrayList<>())); + fields.put(TcfCaV1Field.DISCLOSED_VENDORS, new EncodableOptimizedFibonacciRange(new ArrayList<>())); return fields; } diff --git a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/GppModelTest.java b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/GppModelTest.java index bb5f2336..85146d16 100644 --- a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/GppModelTest.java +++ b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/GppModelTest.java @@ -373,7 +373,7 @@ public void testEncodeUspV1AndTcfEuV2AndTcfCaV1() { String gppString = gppModel.encode(); Assertions.assertEquals( - "DBACOeA~CPSG_8APSG_8ANwAAAENAwCAAAAAAAAAAAAAAAAAAAAA.QAAA.IAAA~BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBgABABAAABAB4AACACAAA.fHHHA4444ao~1YNN", + "DBACOeA~CPSG_8APSG_8ANwAAAENAwCAAAAAAAAAAAAAAAAAAAAA.QAAA.IAAA~BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBhADVqxGAD0AILVgAA.fHHHA4444ao~1YNN", gppString); Assertions.assertEquals(4, gppString.split("~").length); @@ -564,7 +564,7 @@ public void testDecodeUspv1AndTcfEuV2() { @Test public void testDecodeUspv1AndTcfEuV2AndTcfCaV1() { String gppString = - "DBACOeA~CPSG_8APSG_8ANwAAAENAwCAAAAAAAAAAAAAAAAAAAAA.QAAA.IAAA~BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBgABABAAABAB4AACACAAA.fHHHA4444ao~1YNN"; + "DBACOeA~CPSG_8APSG_8ANwAAAENAwCAAAAAAAAAAAAAAAAAAAAA.QAAA.IAAA~BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBhADVqxGAD0AILVgAA.fHHHA4444ao~1YNN"; GppModel gppModel = new GppModel(gppString); Assertions.assertEquals(Arrays.asList(2, 5, 6), gppModel.getSectionIds()); diff --git a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java index 3f53a288..0f66cf05 100644 --- a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java +++ b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java @@ -56,7 +56,7 @@ public void testEncode2() { tcfCaV1.setFieldValue(TcfCaV1Field.CREATED, ZonedDateTime.of(2022, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"))); tcfCaV1.setFieldValue(TcfCaV1Field.LAST_UPDATED, ZonedDateTime.of(2022, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"))); - Assertions.assertEquals("BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBgABABAAABAB4AACACAAA.fHHHA4444ao", tcfCaV1.encode()); + Assertions.assertEquals("BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBhADVqxGAD0AILVgAA.fHHHA4444ao", tcfCaV1.encode()); } @Test @@ -129,7 +129,7 @@ public void testDecode1() { @Test public void testDecode2() { - TcfCaV1 tcfCaV1 = new TcfCaV1("BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBgABABAAABAB4AACACAAA.fHHHA4444ao"); + TcfCaV1 tcfCaV1 = new TcfCaV1("BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBhADVqxGAD0AILVgAA.fHHHA4444ao"); Assertions.assertEquals(50, tcfCaV1.getCmpId()); Assertions.assertEquals(2, tcfCaV1.getCmpVersion()); @@ -185,6 +185,21 @@ public void testDecode4() throws DecodingException { Assertions.assertEquals(Arrays.asList(1, 2, 3, 5, 6, 7, 9), pubRestictions.get(0).getIds()); } + @Test + public void testEncodeDecodeVendorRangeRoundTrip() { + // Sparse, high vendor IDs force the OptimizedRange to choose the (Fibonacci) range + // representation over a bitfield, exercising the Fibonacci range encode/decode path. + TcfCaV1 tcfCaV1 = new TcfCaV1(); + tcfCaV1.setFieldValue(TcfCaV1Field.VENDOR_EXPRESS_CONSENT, Arrays.asList(1, 100, 200)); + tcfCaV1.setFieldValue(TcfCaV1Field.VENDOR_IMPLIED_CONSENT, Arrays.asList(50, 51, 52, 999)); + tcfCaV1.setFieldValue(TcfCaV1Field.DISCLOSED_VENDORS, Arrays.asList(2, 250, 600)); + + TcfCaV1 decoded = new TcfCaV1(tcfCaV1.encode()); + Assertions.assertEquals(Arrays.asList(1, 100, 200), decoded.getVendorExpressConsent()); + Assertions.assertEquals(Arrays.asList(50, 51, 52, 999), decoded.getVendorImpliedConsent()); + Assertions.assertEquals(Arrays.asList(2, 250, 600), decoded.getDisclosedVendors()); + } + @Test() public void testDecodeGarbage1() { Assertions.assertThrows(DecodingException.class, () -> { From 46abab2b5dfe297c600d83eec1b5a8b7d29f81ba Mon Sep 17 00:00:00 2001 From: Chad Huff Date: Tue, 9 Jun 2026 07:27:25 -0600 Subject: [PATCH 2/4] fix: encode TcfCaV1 PubRestrictions ids as Fibonacci OptimizedRange Per the GPP spec, PubRestrictions is N-ArrayOfRanges(6,2) where each record's ids are an OptimizedRange (Fibonacci coded). It was using EncodableArrayOfFixedIntegerRanges, which encodes ids as fixed-integer ranges (the legacy ArrayOfRanges form shared with TCF EU). Add EncodableArrayOfOptimizedFibonacciRanges, which reuses the existing EncodableOptimizedFibonacciRange / OptimizedFibonacciRangeEncoder for each record's ids, and point TcfCaV1's PUB_RESTRICTIONS field at it. The fixed variant is left untouched for TCF EU. Regenerate the affected TcfCaV1Test vectors and extend the round-trip test to cover PubRestrictions with sparse ids (Fibonacci range path). Note: verified by Java/ES cross-implementation agreement and round-trip, not yet against an external reference string. mvn test: 365 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 --- ...odableArrayOfOptimizedFibonacciRanges.java | 118 ++++++++++++++++++ .../encoder/segment/TcfCaV1CoreSegment.java | 4 +- .../iab/gpp/encoder/section/TcfCaV1Test.java | 18 ++- 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/EncodableArrayOfOptimizedFibonacciRanges.java diff --git a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/EncodableArrayOfOptimizedFibonacciRanges.java b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/EncodableArrayOfOptimizedFibonacciRanges.java new file mode 100644 index 00000000..d42fb640 --- /dev/null +++ b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/EncodableArrayOfOptimizedFibonacciRanges.java @@ -0,0 +1,118 @@ +package com.iab.gpp.encoder.datatype; + +import java.util.ArrayList; +import java.util.List; +import com.iab.gpp.encoder.datatype.encoder.FixedIntegerEncoder; +import com.iab.gpp.encoder.datatype.encoder.OptimizedFibonacciRangeEncoder; +import com.iab.gpp.encoder.error.DecodingException; +import com.iab.gpp.encoder.error.EncodingException; + +/** + * Encodes an N-ArrayOfRanges(X,Y) field where each record's ids are encoded as an OptimizedRange + * (Fibonacci coded), per the GPP Consent String Specification. This is the counterpart to + * {@link EncodableArrayOfFixedIntegerRanges}, which encodes ids using fixed-integer ranges for + * downward compatibility (e.g. TCF EU). + */ +public class EncodableArrayOfOptimizedFibonacciRanges extends AbstractEncodableBitStringDataType> { + + private int keyBitStringLength; + private int typeBitStringLength; + + protected EncodableArrayOfOptimizedFibonacciRanges(int keyBitStringLength, int typeBitStringLength) { + super(true); + this.keyBitStringLength = keyBitStringLength; + this.typeBitStringLength = typeBitStringLength; + } + + public EncodableArrayOfOptimizedFibonacciRanges(int keyBitStringLength, int typeBitStringLength, + List value) { + super(true); + this.keyBitStringLength = keyBitStringLength; + this.typeBitStringLength = typeBitStringLength; + setValue(value); + } + + public EncodableArrayOfOptimizedFibonacciRanges(int keyBitStringLength, int typeBitStringLength, + List value, boolean hardFailIfMissing) { + super(hardFailIfMissing); + this.keyBitStringLength = keyBitStringLength; + this.typeBitStringLength = typeBitStringLength; + setValue(value); + } + + @Override + public String encode() { + try { + List entries = this.value; + + StringBuilder sb = new StringBuilder(); + sb.append(FixedIntegerEncoder.encode(entries.size(), 12)); + for (RangeEntry entry : entries) { + sb.append(FixedIntegerEncoder.encode(entry.getKey(), keyBitStringLength)) + .append(FixedIntegerEncoder.encode(entry.getType(), typeBitStringLength)) + .append(OptimizedFibonacciRangeEncoder.encode(entry.getIds())); + } + + return sb.toString(); + } catch (Exception e) { + throw new EncodingException(e); + } + } + + @Override + public void decode(String bitString) { + try { + List entries = new ArrayList<>(); + + int size = FixedIntegerEncoder.decode(bitString.substring(0, 12)); + int index = 12; + for (int i = 0; i < size; i++) { + int key = FixedIntegerEncoder.decode(bitString.substring(index, index + keyBitStringLength)); + index += keyBitStringLength; + + int type = FixedIntegerEncoder.decode(bitString.substring(index, index + typeBitStringLength)); + index += typeBitStringLength; + + String substring = new EncodableOptimizedFibonacciRange(new ArrayList<>()).substring(bitString, index); + List ids = OptimizedFibonacciRangeEncoder.decode(substring); + index += substring.length(); + + entries.add(new RangeEntry(key, type, ids)); + } + + this.value = entries; + } catch (Exception e) { + throw new DecodingException(e); + } + } + + @Override + public String substring(String bitString, int fromIndex) throws SubstringException { + try { + StringBuilder sb = new StringBuilder(); + sb.append(bitString.substring(fromIndex, fromIndex + 12)); + + int size = FixedIntegerEncoder.decode(sb.toString()); + + int index = fromIndex + sb.length(); + for (int i = 0; i < size; i++) { + String keySubstring = bitString.substring(index, index + keyBitStringLength); + index += keySubstring.length(); + sb.append(keySubstring); + + String typeSubstring = bitString.substring(index, index + typeBitStringLength); + index += typeSubstring.length(); + sb.append(typeSubstring); + + String rangeSubstring = new EncodableOptimizedFibonacciRange(new ArrayList<>()).substring(bitString, index); + index += rangeSubstring.length(); + sb.append(rangeSubstring); + } + + return sb.toString(); + } catch (Exception e) { + throw new SubstringException(e); + } + } + +} diff --git a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1CoreSegment.java b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1CoreSegment.java index 7e63b265..86466485 100644 --- a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1CoreSegment.java +++ b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1CoreSegment.java @@ -7,7 +7,7 @@ import com.iab.gpp.encoder.base64.AbstractBase64UrlEncoder; import com.iab.gpp.encoder.base64.CompressedBase64UrlEncoder; import com.iab.gpp.encoder.bitstring.BitStringEncoder; -import com.iab.gpp.encoder.datatype.EncodableArrayOfFixedIntegerRanges; +import com.iab.gpp.encoder.datatype.EncodableArrayOfOptimizedFibonacciRanges; import com.iab.gpp.encoder.datatype.EncodableBoolean; import com.iab.gpp.encoder.datatype.EncodableDatetime; import com.iab.gpp.encoder.datatype.EncodableFixedBitfield; @@ -63,7 +63,7 @@ protected EncodableBitStringFields initializeFields() { false, false, false, false, false, false, false, false, false, false, false, false, false, false))); fields.put(TcfCaV1Field.VENDOR_EXPRESS_CONSENT, new EncodableOptimizedFibonacciRange(new ArrayList<>())); fields.put(TcfCaV1Field.VENDOR_IMPLIED_CONSENT, new EncodableOptimizedFibonacciRange(new ArrayList<>())); - fields.put(TcfCaV1Field.PUB_RESTRICTIONS, new EncodableArrayOfFixedIntegerRanges(6, 2, new ArrayList<>(), false)); + fields.put(TcfCaV1Field.PUB_RESTRICTIONS, new EncodableArrayOfOptimizedFibonacciRanges(6, 2, new ArrayList<>(), false)); return fields; } diff --git a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java index 0f66cf05..68192b8d 100644 --- a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java +++ b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java @@ -82,7 +82,7 @@ public void testEncode4() throws EncodingException, InvalidFieldException { tcfCaV1.setFieldValue(TcfCaV1Field.CREATED, ZonedDateTime.of(2022, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"))); tcfCaV1.setFieldValue(TcfCaV1Field.LAST_UPDATED, ZonedDateTime.of(2022, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"))); - Assertions.assertEquals("BPSG_8APSG_8AAAAAAENAACAAAAAAAAAAAAAAAAACCgBwABAAOAAoADgAJA.YAAAAAAAAAA", tcfCaV1.encode()); + Assertions.assertEquals("BPSG_8APSG_8AAAAAAENAACAAAAAAAAAAAAAAAAACCgAS7o.YAAAAAAAAAA", tcfCaV1.encode()); } @Test @@ -176,7 +176,7 @@ public void testDecode3() throws DecodingException { @Test public void testDecode4() throws DecodingException { - TcfCaV1 tcfCaV1 = new TcfCaV1("BPSG_8APSG_8AAAAAAENAACAAAAAAAAAAAAAAAAACCgBwABAAOAAoADgAJA.YAAAAAAAAAA"); + TcfCaV1 tcfCaV1 = new TcfCaV1("BPSG_8APSG_8AAAAAAENAACAAAAAAAAAAAAAAAAACCgAS7o.YAAAAAAAAAA"); List pubRestictions = tcfCaV1.getPubRestrictions(); Assertions.assertEquals(1, pubRestictions.size()); @@ -189,15 +189,29 @@ public void testDecode4() throws DecodingException { public void testEncodeDecodeVendorRangeRoundTrip() { // Sparse, high vendor IDs force the OptimizedRange to choose the (Fibonacci) range // representation over a bitfield, exercising the Fibonacci range encode/decode path. + List pubRestrictions = new ArrayList<>(); + pubRestrictions.add(new RangeEntry(1, 0, Arrays.asList(5, 100, 101, 102, 800))); + pubRestrictions.add(new RangeEntry(2, 2, Arrays.asList(3, 500))); + TcfCaV1 tcfCaV1 = new TcfCaV1(); tcfCaV1.setFieldValue(TcfCaV1Field.VENDOR_EXPRESS_CONSENT, Arrays.asList(1, 100, 200)); tcfCaV1.setFieldValue(TcfCaV1Field.VENDOR_IMPLIED_CONSENT, Arrays.asList(50, 51, 52, 999)); tcfCaV1.setFieldValue(TcfCaV1Field.DISCLOSED_VENDORS, Arrays.asList(2, 250, 600)); + tcfCaV1.setFieldValue(TcfCaV1Field.PUB_RESTRICTIONS, pubRestrictions); TcfCaV1 decoded = new TcfCaV1(tcfCaV1.encode()); Assertions.assertEquals(Arrays.asList(1, 100, 200), decoded.getVendorExpressConsent()); Assertions.assertEquals(Arrays.asList(50, 51, 52, 999), decoded.getVendorImpliedConsent()); Assertions.assertEquals(Arrays.asList(2, 250, 600), decoded.getDisclosedVendors()); + + List decodedPubRestrictions = decoded.getPubRestrictions(); + Assertions.assertEquals(2, decodedPubRestrictions.size()); + Assertions.assertEquals(1, decodedPubRestrictions.get(0).getKey()); + Assertions.assertEquals(0, decodedPubRestrictions.get(0).getType()); + Assertions.assertEquals(Arrays.asList(5, 100, 101, 102, 800), decodedPubRestrictions.get(0).getIds()); + Assertions.assertEquals(2, decodedPubRestrictions.get(1).getKey()); + Assertions.assertEquals(2, decodedPubRestrictions.get(1).getType()); + Assertions.assertEquals(Arrays.asList(3, 500), decodedPubRestrictions.get(1).getIds()); } @Test() From d5d473d0323906616389f4a062b5e97936353ad3 Mon Sep 17 00:00:00 2001 From: Chad Huff Date: Tue, 9 Jun 2026 07:49:02 -0600 Subject: [PATCH 3/4] feat: backwards-compatible decoding of legacy TcfCaV1 strings Strings produced by the pre-fix encoder used fixed-integer ranges for the OptimizedRange / N-ArrayOfRanges fields (VendorExpressConsent, VendorImpliedConsent, DisclosedVendors, PubRestrictions). Decoding those with the new Fibonacci datatypes would misread the data. The TcfCaV1 core and disclosed-vendors segments now decode by trying the spec-compliant (Fibonacci) interpretation first and, if it does not round-trip back to the input bitstring, falling back to the legacy (fixed-range) interpretation. Whichever re-encodes to the original bits is the one that produced the string. Decoded values are copied into the current (Fibonacci) fields, so the in-memory representation is identical regardless of which encoder produced the string. Tests: decode real pre-fix strings (vendors, PubRestrictions) and assert correct values. mvn test: 367 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 --- .../encoder/segment/TcfCaV1CoreSegment.java | 70 +++++++++++++++++-- .../TcfCaV1DisclosedVendorsSegment.java | 52 +++++++++++++- .../iab/gpp/encoder/section/TcfCaV1Test.java | 24 +++++++ 3 files changed, 139 insertions(+), 7 deletions(-) diff --git a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1CoreSegment.java b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1CoreSegment.java index 86466485..9f3b5907 100644 --- a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1CoreSegment.java +++ b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1CoreSegment.java @@ -7,6 +7,7 @@ import com.iab.gpp.encoder.base64.AbstractBase64UrlEncoder; import com.iab.gpp.encoder.base64.CompressedBase64UrlEncoder; import com.iab.gpp.encoder.bitstring.BitStringEncoder; +import com.iab.gpp.encoder.datatype.EncodableArrayOfFixedIntegerRanges; import com.iab.gpp.encoder.datatype.EncodableArrayOfOptimizedFibonacciRanges; import com.iab.gpp.encoder.datatype.EncodableBoolean; import com.iab.gpp.encoder.datatype.EncodableDatetime; @@ -14,6 +15,7 @@ import com.iab.gpp.encoder.datatype.EncodableFixedInteger; import com.iab.gpp.encoder.datatype.EncodableFixedString; import com.iab.gpp.encoder.datatype.EncodableOptimizedFibonacciRange; +import com.iab.gpp.encoder.datatype.EncodableOptimizedFixedRange; import com.iab.gpp.encoder.error.DecodingException; import com.iab.gpp.encoder.field.EncodableBitStringFields; import com.iab.gpp.encoder.field.TcfCaV1Field; @@ -37,11 +39,21 @@ public TcfCaV1CoreSegment(String encodedString) { public List getFieldNames() { return TcfCaV1Field.TCFCAV1_CORE_SEGMENT_FIELD_NAMES; } - + @Override protected EncodableBitStringFields initializeFields() { + return buildFields(false); + } + + /** + * Builds the core field set. When {@code legacy} is true the OptimizedRange / N-ArrayOfRanges + * fields use the pre-fix fixed-integer encoders; otherwise they use the spec-compliant Fibonacci + * encoders. The legacy field set is only used to decode strings produced by the older encoder + * (see {@link #decodeSegment}). + */ + private EncodableBitStringFields buildFields(boolean legacy) { ZonedDateTime date = ZonedDateTime.now(); - + EncodableBitStringFields fields = new EncodableBitStringFields(); fields.put(TcfCaV1Field.VERSION, new EncodableFixedInteger(6, TcfCaV1.VERSION)); fields.put(TcfCaV1Field.CREATED, new EncodableDatetime(date)); @@ -61,9 +73,17 @@ protected EncodableBitStringFields initializeFields() { fields.put(TcfCaV1Field.PURPOSES_IMPLIED_CONSENT, new EncodableFixedBitfield(Arrays.asList(false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false))); - fields.put(TcfCaV1Field.VENDOR_EXPRESS_CONSENT, new EncodableOptimizedFibonacciRange(new ArrayList<>())); - fields.put(TcfCaV1Field.VENDOR_IMPLIED_CONSENT, new EncodableOptimizedFibonacciRange(new ArrayList<>())); - fields.put(TcfCaV1Field.PUB_RESTRICTIONS, new EncodableArrayOfOptimizedFibonacciRanges(6, 2, new ArrayList<>(), false)); + + if (legacy) { + fields.put(TcfCaV1Field.VENDOR_EXPRESS_CONSENT, new EncodableOptimizedFixedRange(new ArrayList<>())); + fields.put(TcfCaV1Field.VENDOR_IMPLIED_CONSENT, new EncodableOptimizedFixedRange(new ArrayList<>())); + fields.put(TcfCaV1Field.PUB_RESTRICTIONS, new EncodableArrayOfFixedIntegerRanges(6, 2, new ArrayList<>(), false)); + } else { + fields.put(TcfCaV1Field.VENDOR_EXPRESS_CONSENT, new EncodableOptimizedFibonacciRange(new ArrayList<>())); + fields.put(TcfCaV1Field.VENDOR_IMPLIED_CONSENT, new EncodableOptimizedFibonacciRange(new ArrayList<>())); + fields.put(TcfCaV1Field.PUB_RESTRICTIONS, + new EncodableArrayOfOptimizedFibonacciRanges(6, 2, new ArrayList<>(), false)); + } return fields; } @@ -76,14 +96,52 @@ protected String encodeSegment(EncodableBitStringFields fields) { @Override protected void decodeSegment(String encodedString, EncodableBitStringFields fields) { - if(encodedString == null || encodedString.isEmpty()) { + if (encodedString == null || encodedString.isEmpty()) { this.fields.reset(fields); } try { String bitString = base64UrlEncoder.decode(encodedString); + + // Prefer the spec-compliant (Fibonacci OptimizedRange) interpretation. If that doesn't + // round-trip back to the input, fall back to the legacy (fixed-range) interpretation used by + // the pre-fix encoder. This keeps older strings decodable; re-encoding always migrates them + // to the spec-compliant format because the fields decode into the Fibonacci datatypes. + if (tryDecode(bitString, fields, false)) { + return; + } + if (tryDecode(bitString, fields, true)) { + return; + } + + // Neither interpretation round-trips cleanly; decode with the current interpretation as a + // best effort and surface any decoding error. bitStringEncoder.decode(bitString, getFieldNames(), fields); } catch (Exception e) { throw new DecodingException("Unable to decode TcfCaV1CoreSegment '" + encodedString + "'", e); } } + + /** + * Attempts to decode {@code bitString} using either the current or legacy field set and verifies + * the result by re-encoding it: if the re-encoded bits are a prefix of the decoded bits (the tail + * being base64 padding), the interpretation produced the string. On success the decoded values + * are copied into {@code targetFields} (which always use the current encoders) so that any + * subsequent re-encode emits the spec-compliant format. + */ + private boolean tryDecode(String bitString, EncodableBitStringFields targetFields, boolean legacy) { + try { + EncodableBitStringFields candidate = buildFields(legacy); + bitStringEncoder.decode(bitString, getFieldNames(), candidate); + String reEncoded = bitStringEncoder.encode(candidate, getFieldNames()); + if (bitString.startsWith(reEncoded)) { + for (String fieldName : getFieldNames()) { + targetFields.get(fieldName).setValue(candidate.get(fieldName).getValue()); + } + return true; + } + } catch (Exception e) { + // This interpretation does not apply; the caller will try the next one. + } + return false; + } } diff --git a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1DisclosedVendorsSegment.java b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1DisclosedVendorsSegment.java index 3afc3516..a8fac739 100644 --- a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1DisclosedVendorsSegment.java +++ b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/segment/TcfCaV1DisclosedVendorsSegment.java @@ -7,6 +7,7 @@ import com.iab.gpp.encoder.bitstring.BitStringEncoder; import com.iab.gpp.encoder.datatype.EncodableFixedInteger; import com.iab.gpp.encoder.datatype.EncodableOptimizedFibonacciRange; +import com.iab.gpp.encoder.datatype.EncodableOptimizedFixedRange; import com.iab.gpp.encoder.error.DecodingException; import com.iab.gpp.encoder.field.EncodableBitStringFields; import com.iab.gpp.encoder.field.TcfCaV1Field; @@ -32,9 +33,23 @@ public List getFieldNames() { @Override protected EncodableBitStringFields initializeFields() { + return buildFields(false); + } + + /** + * Builds the disclosed-vendors field set. When {@code legacy} is true the OptimizedRange field + * uses the pre-fix fixed-integer encoder; otherwise it uses the spec-compliant Fibonacci encoder. + * The legacy field set is only used to decode strings produced by the older encoder (see + * {@link #decodeSegment}). + */ + private EncodableBitStringFields buildFields(boolean legacy) { EncodableBitStringFields fields = new EncodableBitStringFields(); fields.put(TcfCaV1Field.DISCLOSED_VENDORS_SEGMENT_TYPE, new EncodableFixedInteger(3, 1)); - fields.put(TcfCaV1Field.DISCLOSED_VENDORS, new EncodableOptimizedFibonacciRange(new ArrayList<>())); + if (legacy) { + fields.put(TcfCaV1Field.DISCLOSED_VENDORS, new EncodableOptimizedFixedRange(new ArrayList<>())); + } else { + fields.put(TcfCaV1Field.DISCLOSED_VENDORS, new EncodableOptimizedFibonacciRange(new ArrayList<>())); + } return fields; } @@ -52,9 +67,44 @@ protected void decodeSegment(String encodedString, EncodableBitStringFields fiel } try { String bitString = base64UrlEncoder.decode(encodedString); + + // Prefer the spec-compliant (Fibonacci OptimizedRange) interpretation, falling back to the + // legacy (fixed-range) interpretation used by the pre-fix encoder. Re-encoding always + // migrates to the spec-compliant format because the values decode into the Fibonacci datatype. + if (tryDecode(bitString, fields, false)) { + return; + } + if (tryDecode(bitString, fields, true)) { + return; + } + bitStringEncoder.decode(bitString, getFieldNames(), fields); } catch (Exception e) { throw new DecodingException("Unable to decode TcfCaV1DisclosedVendorsSegment '" + encodedString + "'", e); } } + + /** + * Attempts to decode {@code bitString} using either the current or legacy field set and verifies + * the result by re-encoding it: if the re-encoded bits are a prefix of the decoded bits (the tail + * being base64 padding), the interpretation produced the string. On success the decoded values + * are copied into {@code targetFields} (which always use the current encoders) so that any + * subsequent re-encode emits the spec-compliant format. + */ + private boolean tryDecode(String bitString, EncodableBitStringFields targetFields, boolean legacy) { + try { + EncodableBitStringFields candidate = buildFields(legacy); + bitStringEncoder.decode(bitString, getFieldNames(), candidate); + String reEncoded = bitStringEncoder.encode(candidate, getFieldNames()); + if (bitString.startsWith(reEncoded)) { + for (String fieldName : getFieldNames()) { + targetFields.get(fieldName).setValue(candidate.get(fieldName).getValue()); + } + return true; + } + } catch (Exception e) { + // This interpretation does not apply; the caller will try the next one. + } + return false; + } } diff --git a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java index 68192b8d..98bbcf65 100644 --- a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java +++ b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java @@ -214,6 +214,30 @@ public void testEncodeDecodeVendorRangeRoundTrip() { Assertions.assertEquals(Arrays.asList(3, 500), decodedPubRestrictions.get(1).getIds()); } + @Test + public void testDecodeLegacyFixedRangeVendors() { + // String produced by the pre-fix encoder, which used fixed-integer ranges for the + // VendorExpressConsent / VendorImpliedConsent OptimizedRange fields. The decoder must still + // read it correctly via the backwards-compatible fallback. + TcfCaV1 tcfCaV1 = new TcfCaV1("BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBgABABAAABAB4AACACAAA.fHHHA4444ao"); + + Assertions.assertEquals(Arrays.asList(12, 24, 48), tcfCaV1.getVendorExpressConsent()); + Assertions.assertEquals(Arrays.asList(18, 30), tcfCaV1.getVendorImpliedConsent()); + } + + @Test + public void testDecodeLegacyFixedRangePubRestrictions() { + // String produced by the pre-fix encoder, which used fixed-integer ranges for the + // PubRestrictions ids. + TcfCaV1 tcfCaV1 = new TcfCaV1("BPSG_8APSG_8AAAAAAENAACAAAAAAAAAAAAAAAAACCgBwABAAOAAoADgAJA.YAAAAAAAAAA"); + + List pubRestrictions = tcfCaV1.getPubRestrictions(); + Assertions.assertEquals(1, pubRestrictions.size()); + Assertions.assertEquals(1, pubRestrictions.get(0).getKey()); + Assertions.assertEquals(1, pubRestrictions.get(0).getType()); + Assertions.assertEquals(Arrays.asList(1, 2, 3, 5, 6, 7, 9), pubRestrictions.get(0).getIds()); + } + @Test() public void testDecodeGarbage1() { Assertions.assertThrows(DecodingException.class, () -> { From f9f559ce798954f937a2a6b8a72360dc5fe55c80 Mon Sep 17 00:00:00 2001 From: Chad Huff Date: Tue, 9 Jun 2026 08:01:26 -0600 Subject: [PATCH 4/4] test: decode a real legacy TcfCaV1 string and re-encode to spec-compliant Adds a regression test using a real pre-fix (fixed-range) TcfCaV1 string: verifies it decodes correctly (CmpId, language, 14 express + 46 implied vendors) and that re-encoding emits the spec-compliant Fibonacci form. mvn test: 368 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 --- .../iab/gpp/encoder/section/TcfCaV1Test.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java index 98bbcf65..7442cb44 100644 --- a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java +++ b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java @@ -238,6 +238,37 @@ public void testDecodeLegacyFixedRangePubRestrictions() { Assertions.assertEquals(Arrays.asList(1, 2, 3, 5, 6, 7, 9), pubRestrictions.get(0).getIds()); } + @Test + public void testDecodeLegacyStringAndReencodeToSpecCompliant() { + // A real TcfCaV1 string produced by the pre-fix encoder (fixed-integer OptimizedRange). The + // backwards-compatible decoder reads it, and re-encoding emits the spec-compliant Fibonacci form. + String legacy = + "BQliWsAQliWsAPoABAELC9CoAKgAAJIAAApNAOABUAC0AGgAQwAlgBQAC6AG0AO4AfgBBATAAnMBSYEwYFgAXQBOwC3ALgAc4A7gCAAEmAJ2AT8AxQBmgDOgGfANeAcQA6oCJgEngJyAT-Ao8BUQCpQFvALhAXQAvcBf4DMAGggNNAbUA3EBxoDlgHiAPNAfIBAQCEgEbgI_gSlgmACYIAA.YAAAAAAAAAA"; + TcfCaV1 tcfCaV1 = new TcfCaV1(legacy); + + Assertions.assertEquals(1000, tcfCaV1.getCmpId()); + Assertions.assertEquals(1, tcfCaV1.getCmpVersion()); + Assertions.assertEquals("EL", tcfCaV1.getConsentLanguage()); + Assertions.assertEquals(189, tcfCaV1.getVendorListVersion()); + Assertions.assertEquals(true, tcfCaV1.getUseNonStandardStacks()); + Assertions.assertEquals(Arrays.asList(42, 45, 52, 67, 75, 80, 93, 109, 119, 126, 130, 1216, 1254, 1318), + tcfCaV1.getVendorExpressConsent()); + Assertions.assertEquals( + Arrays.asList(93, 157, 183, 184, 231, 238, 256, 294, 315, 319, 394, 410, 413, 415, 431, 452, 469, 550, 591, 626, + 639, 655, 674, 677, 734, 737, 744, 759, 767, 816, 833, 845, 874, 881, 909, 918, 964, 973, 996, 1028, 1060, + 1134, 1151, 1189, 1216, 1217), + tcfCaV1.getVendorImpliedConsent()); + + // Touching the timestamps (preserving their values) marks the core segment dirty so encode() + // re-emits the string; setting Created/LastUpdated does not trigger the automatic "now" update. + tcfCaV1.setFieldValue(TcfCaV1Field.CREATED, tcfCaV1.getCreated()); + tcfCaV1.setFieldValue(TcfCaV1Field.LAST_UPDATED, tcfCaV1.getLastUpdated()); + + Assertions.assertEquals( + "BQliWsAQliWsAPoABAELC9CoAKgAAJIAAApNAOBMZZGDDAxMmWskIahojBMGBYoGiOJ4FlhahgNZUxMZiYDUwllGgYGJpYyBjLIwZFqasFllNGqaMhisVpTU1DyeAAA.YAAAAAAAAAA", + tcfCaV1.encode()); + } + @Test() public void testDecodeGarbage1() { Assertions.assertThrows(DecodingException.class, () -> {