diff --git a/api/consent.go b/api/consent.go index 64fec55..2700eb9 100644 --- a/api/consent.go +++ b/api/consent.go @@ -61,4 +61,26 @@ type VendorConsents interface { // It is the caller's responsibility to get the right Vendor List version for the semantics of the ID. // For more information, see VendorListVersion(). VendorConsent(id uint16) bool + + // VendorDisclosed determines if a given vendor was disclosed to the user. + // This is mandatory in TCF 2.3 and is used to verify that vendors (especially those + // declaring only Special Purposes) were actually presented to the user. + // Mandatory Inclusion: All TC Strings created on or after February 28, 2026, must include the disclosedVendors segment. + // Source https://iabeurope.eu/all-you-need-to-know-about-the-transition-to-tcf-v2-3/#:~:text=Any%20TC%20String%20created%20without,TC%20String%20created%20under%202.3. + // + // For TCF 2.2 strings that don't include a DisclosedVendors segment, + // this returns false to maintain backward compatibility. + VendorDisclosed(id uint16) bool + + VendorDisclosedMaxVendorId() uint16 + + // HasDisclosedVendors returns true if the consent string includes a disclosedVendors segment. + // This method is particularly useful during the TCF 2.3 transition phase (mandatory from March 1st, 2025) + // to distinguish between: + // - Legacy TCF 2.0/2.2 consent strings that legitimately don't have this segment (returns false) + // - TCF 2.3+ consent strings that should have this segment (returns true if present, false if malformed) + // + // Note: VendorDisclosed(id) returns false both when the segment is missing AND when + // a vendor is not disclosed, so use HasDisclosedVendors() to disambiguate these cases. + HasDisclosedVendors() bool } diff --git a/go.mod b/go.mod index 34f3fc4..4427d9b 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,17 @@ module github.com/prebid/go-gdpr -go 1.13 +go 1.23 require ( github.com/buger/jsonparser v1.1.1 github.com/stretchr/testify v1.7.0 ) +require ( + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) + // Version v0.9.0 was accidentally tagged as v1.9.0. retract v1.9.0 diff --git a/go.sum b/go.sum index 1230ed5..31c4ceb 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/buger/jsonparser v0.0.0-20180318095312-2cac668e8456 h1:SnUWpAH4lEUoS86woR12h21VMUbDe+DYp88V646wwMI= -github.com/buger/jsonparser v0.0.0-20180318095312-2cac668e8456/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= @@ -9,6 +7,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/vendorconsent/consent20_test.go b/vendorconsent/consent20_test.go index e8660e5..a62d9b9 100644 --- a/vendorconsent/consent20_test.go +++ b/vendorconsent/consent20_test.go @@ -47,10 +47,10 @@ func TestInvalidConsentStrings20(t *testing.T) { assertInvalid20(t, "CONciguONcjGKADACHENAACIAC0ta__AACiQABgAAYA", "the consent string encoded a VendorListVersion of 0, but this value must be greater than or equal to 1") // Bad BitFields - assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQAeAA", "a BitField for 60 vendors requires a consent string of 36 bytes. This consent string had 30") + assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQAeAA", "a BitField for 60 vendors requires a consent string of 37 bytes. This consent string had 30") // Bad RangeSections - assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQABwA", "vendor consent strings using RangeSections require at least 31 bytes. Got 30") // This encodes 184 bits + assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQABwA", "vendor consent strings using RangeSections require at least 31 bytes to read NumEntries. Got 30") // This encodes 184 bits assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQABwAQQ", "ParseUInt16 expected a 16-bit int to start at bit 243, but the consent string was only 31 bytes long") // 1 single vendor, too few bits assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQABwAYQAC", "ParseUInt16 expected a 16-bit int to start at bit 259, but the consent string was only 33 bytes long") // 1 vendor range, too few bits assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQABwAgABA", "ParseUInt16 expected a 16-bit int to start at bit 260, but the consent string was only 33 bytes long") // 2 single vendors, too few bits diff --git a/vendorconsent/tcf1/metadata.go b/vendorconsent/tcf1/metadata.go index f9594f5..68bbf68 100644 --- a/vendorconsent/tcf1/metadata.go +++ b/vendorconsent/tcf1/metadata.go @@ -131,6 +131,19 @@ func (c consentMetadata) PurposeAllowed(id consentconstants.Purpose) bool { return isSet(c, uint(id)+131) } +// VendorDisclosed always returns false for TCF1 (disclosed vendors is a TCF 2.3 feature). +func (c consentMetadata) VendorDisclosed(id uint16) bool { + return false +} + +func (c consentMetadata) VendorDisclosedMaxVendorId() uint16 { + return 0 +} + +func (c consentMetadata) HasDisclosedVendors() bool { + return false +} + // Returns true if the bitIndex'th bit in data is a 1, and false if it's a 0. func isSet(data []byte, bitIndex uint) bool { byteIndex := bitIndex / 8 diff --git a/vendorconsent/tcf1/metadata_test.go b/vendorconsent/tcf1/metadata_test.go index 5f4eac2..f273b10 100644 --- a/vendorconsent/tcf1/metadata_test.go +++ b/vendorconsent/tcf1/metadata_test.go @@ -64,3 +64,22 @@ func TestLanguageExtremes(t *testing.T) { assertNilError(t, err) assertStringsEqual(t, "ZA", consent.ConsentLanguage()) } + +func TestVendorDisclosed(t *testing.T) { + consent, err := Parse(decode(t, "BOOG4uyOOG4uyABFZBAAABAAAAAAEA")) + assertNilError(t, err) + assertBoolsEqual(t, false, consent.VendorDisclosed(1)) + assertBoolsEqual(t, false, consent.VendorDisclosed(100)) +} + +func TestVendorDisclosedMaxVendorID(t *testing.T) { + consent, err := Parse(decode(t, "BOOG4uyOOG4uyABFZBAAABAAAAAAEA")) + assertNilError(t, err) + assertUInt16sEqual(t, 0, consent.VendorDisclosedMaxVendorId()) +} + +func TestHasDisclosedVendors(t *testing.T) { + consent, err := Parse(decode(t, "BOOG4uyOOG4uyABFZBAAABAAAAAAEA")) + assertNilError(t, err) + assertBoolsEqual(t, false, consent.HasDisclosedVendors()) +} diff --git a/vendorconsent/tcf2/consent.go b/vendorconsent/tcf2/consent.go index 4bd7a95..f86a852 100644 --- a/vendorconsent/tcf2/consent.go +++ b/vendorconsent/tcf2/consent.go @@ -15,28 +15,30 @@ const ( consentStringTCF2Prefix = 'C' ) +// Segment types defined in TCF 2.x specification. +// https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20Consent%20string%20and%20vendor%20list%20formats%20v2.md#publisher-purposes-transparency-and-consent +// Only `SegmentTypeDisclosedVendors` is used in this file, but all types are included for specification completeness. +const ( + SegmentTypeCoreString = 0 + SegmentTypeDisclosedVendors = 1 + SegmentTypePublisherTC = 3 +) + // ParseString parses the TCF 2.0 vendor string base64 encoded func ParseString(consent string) (api.VendorConsents, error) { if consent == "" { return nil, consentconstants.ErrEmptyDecodedConsent } - // split TCF 2.0 segments - if index := strings.IndexByte(consent, consentStringTCF2Separator); index != -1 { - consent = consent[:index] - } - buff := []byte(consent) - decoded := buff - n, err := base64.RawURLEncoding.Decode(decoded, buff) + consentMeta, err := parseCoreAndDisclosedVendors(consent) if err != nil { return nil, err } - decoded = decoded[:n:n] - return Parse(decoded) + return consentMeta, nil } -// Parse parses the TCF 2.0 vendor consent data from the string. This string should *not* be encoded (by base64 or any other encoding). +// Parse parses the TCF 2.0 "Core string" segment. This string should *not* be encoded (by base64 or any other encoding). // If the data is malformed and cannot be interpreted as a vendor consent string, this will return an error. func Parse(data []byte) (api.VendorConsents, error) { metadata, err := parseMetadata(data) @@ -90,10 +92,85 @@ func Parse(data []byte) (api.VendorConsents, error) { metadata.publisherRestrictions = pubRestrictions return metadata, err +} + +func parseCoreAndDisclosedVendors(consent string) (ConsentMetadata, error) { + // Split TCF 2.0 segments by '.' + // Format: [Core String].[Disclosed Vendors].[Publisher TC] + segments := strings.Split(consent, string(consentStringTCF2Separator)) + // Parse the core string (always first segment) + coreSegmentDecoded, err := decodeSegment(segments[0]) + if err != nil { + return ConsentMetadata{}, err + } + + // Parse the core string + result, err := Parse(coreSegmentDecoded) + if err != nil { + return ConsentMetadata{}, err + } + + metadata := result.(ConsentMetadata) + + // Parse disclosed vendors segment if present (TCF 2.3+) + // Iterate through segments to find disclosed vendors by type (segments after Core String segment can be in any order) + for _, segment := range segments[1:] { + if segment == "" { + continue + } + + decoded, err := decodeSegment(segment) + if err != nil { + return ConsentMetadata{}, err + } + + segmentType, err := getSegmentType(decoded) + if err != nil { + return ConsentMetadata{}, err + } + + if segmentType == SegmentTypeDisclosedVendors { // Disclosed Vendors segment + disclosedVendors, err := parseDisclosedVendorsSegment(decoded) + if err != nil { + return ConsentMetadata{}, fmt.Errorf("failed to parse disclosed vendors segment: %v", err) + } + metadata.disclosedVendors = disclosedVendors + metadata.hasDisclosedVendors = true + break + } + } + + return metadata, nil } // IsConsentV2 return true if the consent strings looks like a tcf v2 consent string func IsConsentV2(consent string) bool { return len(consent) > 0 && consent[0] == consentStringTCF2Prefix } + +// decodeSegment decodes a base64 encoded segment string. +func decodeSegment(segmentString string) ([]byte, error) { + if segmentString == "" { + return nil, fmt.Errorf("empty segment string") + } + + buff := []byte(segmentString) + decoded := buff + n, err := base64.RawURLEncoding.Decode(decoded, buff) + if err != nil { + return nil, fmt.Errorf("failed to decode segment: %v", err) + } + + return decoded[:n:n], nil +} + +// getSegmentType extracts the 3-bit segment type from the segment data +func getSegmentType(data []byte) (uint8, error) { + if len(data) < 1 { + return 0, fmt.Errorf("segment too short") + } + + segmentType := data[0] >> 5 + return segmentType, nil +} diff --git a/vendorconsent/tcf2/disclosed_vendors.go b/vendorconsent/tcf2/disclosed_vendors.go new file mode 100644 index 0000000..83604e6 --- /dev/null +++ b/vendorconsent/tcf2/disclosed_vendors.go @@ -0,0 +1,56 @@ +package vendorconsent + +import ( + "fmt" + + "github.com/prebid/go-gdpr/bitutils" +) + +// parseDisclosedVendorsSegment parses the Disclosed Vendors segment (SegmentType=1). +// This segment is mandatory in TCF 2.3. +func parseDisclosedVendorsSegment(data []byte) (vendorConsentsResolver, error) { + if len(data) == 0 { + return nil, fmt.Errorf("data is empty") + } + + // Need at least 3 bits for segment type + 16 bits for MaxVendorId + 1 bit for IsRangeEncoding + if len(data) < 3 { + return nil, fmt.Errorf("segment too short: %d bytes, need at least 3", len(data)) + } + + segmentType, err := bitutils.ParseByte8(data, 0) + if err != nil { + return nil, fmt.Errorf("parse segment type: %v", err) + } + segmentType = segmentType >> 5 // Get first 3 bits + + if segmentType != SegmentTypeDisclosedVendors { + return nil, fmt.Errorf("expected segment type 1, got %d", segmentType) + } + + maxVendorID, err := bitutils.ParseUInt16(data, 3) + if err != nil { + return nil, fmt.Errorf("parse MaxVendorId: %v", err) + } + + // IsRangeEncoding is at bit 19 (0-based indexing) + // see https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20Consent%20string%20and%20vendor%20list%20formats%20v2.md#disclosed-vendors) + isRangeEncoding := isSet(data, 19) + + // Create a temporary metadata just for parsing purposes + tempMetadata := ConsentMetadata{data: data} + + if isRangeEncoding { + rangeSection, _, err := parseRangeSection(tempMetadata, maxVendorID, 20) + if err != nil { + return nil, fmt.Errorf("parse range section: %v", err) + } + return rangeSection, nil + } + + bitField, _, err := parseBitField(tempMetadata, maxVendorID, 20) + if err != nil { + return nil, fmt.Errorf("parse bit field: %v", err) + } + return bitField, nil +} diff --git a/vendorconsent/tcf2/disclosed_vendors_test.go b/vendorconsent/tcf2/disclosed_vendors_test.go new file mode 100644 index 0000000..b04e944 --- /dev/null +++ b/vendorconsent/tcf2/disclosed_vendors_test.go @@ -0,0 +1,153 @@ +package vendorconsent + +import ( + "encoding/base64" + "testing" +) + +// TestParseDisclosedVendors tests parsing of TCF 2.3 strings with disclosed vendors segment +func TestParseDisclosedVendors(t *testing.T) { + // This is a TCF 2.3 string with disclosed vendors segment + // Format: CoreString.DisclosedVendors + + // Core string (existing valid TCF 2.0 string) + coreString := "COyiILmOyiILmADACHENAPCAAAAAAAAAAAAAE5QBgALgAqgD8AQACSwEygJyAAAAAA" + + // Create a disclosed vendors segment manually for testing + // Binary structure: + // - SegmentType=1: 001 (3 bits) + // - MaxVendorId=10: 0000000000001010 (16 bits) + // - IsRangeEncoding=0: 0 (1 bit - bitfield mode) + // - Vendor bits (10 bits): 1010100000 (vendors 1, 3, 5 disclosed) + // + // Bit string: 001|0000000000001010|0|1010100000 + // Bytes: 00100000|00000001|01001010|10000000 + // 0x20 0x01 0x4a 0x80 + disclosedVendorsBytes := []byte{0x20, 0x01, 0x4a, 0x80} + disclosedVendorsString := base64.RawURLEncoding.EncodeToString(disclosedVendorsBytes) + + consentString := coreString + "." + disclosedVendorsString + + consent, err := ParseString(consentString) + assertNilError(t, err) + + // Test that core parsing still works + assertUInt16sEqual(t, 15, consent.VendorListVersion()) + + // Test disclosed vendors + assertBoolsEqual(t, true, consent.VendorDisclosed(1)) + assertBoolsEqual(t, false, consent.VendorDisclosed(2)) + assertBoolsEqual(t, true, consent.VendorDisclosed(3)) + assertBoolsEqual(t, false, consent.VendorDisclosed(4)) + assertBoolsEqual(t, true, consent.VendorDisclosed(5)) + assertBoolsEqual(t, false, consent.VendorDisclosed(6)) + + // Test HasDisclosedVendors + assertBoolsEqual(t, true, consent.HasDisclosedVendors()) +} + +// TestBackwardCompatibilityNoDisclosedVendors tests that TCF 2.0/2.2 strings without +// disclosed vendors segment still work (backward compatibility) +func TestBackwardCompatibilityNoDisclosedVendors(t *testing.T) { + // TCF 2.0 string without disclosed vendors segment + consentString := "COyiILmOyiILmADACHENAPCAAAAAAAAAAAAAE5QBgALgAqgD8AQACSwEygJyAAAAAA" + + consent, err := ParseString(consentString) + assertNilError(t, err) + + // Test that core parsing works + assertUInt16sEqual(t, 15, consent.VendorListVersion()) + + // VendorDisclosed should return false when no disclosed vendors segment exists + assertBoolsEqual(t, false, consent.VendorDisclosed(1)) + assertBoolsEqual(t, false, consent.VendorDisclosed(100)) + + // HasDisclosedVendors should return false + assertBoolsEqual(t, false, consent.HasDisclosedVendors()) +} + +// TestEmptyDisclosedVendorsSegment tests handling of empty disclosed vendors segment +func TestEmptyDisclosedVendorsSegment(t *testing.T) { + // TCF string with empty disclosed vendors segment (just a dot) + consentString := "COyiILmOyiILmADACHENAPCAAAAAAAAAAAAAE5QBgALgAqgD8AQACSwEygJyAAAAAA." + + consent, err := ParseString(consentString) + assertNilError(t, err) + + // Should still parse core string successfully + assertUInt16sEqual(t, 15, consent.VendorListVersion()) + + // VendorDisclosed should return false (no vendors disclosed) + assertBoolsEqual(t, false, consent.VendorDisclosed(1)) + + // HasDisclosedVendors should return false for empty segment + assertBoolsEqual(t, false, consent.HasDisclosedVendors()) +} + +// TestMultipleSegments tests parsing string with multiple segments (core + disclosed + publisher) +func TestMultipleSegments(t *testing.T) { + // Core string + disclosed vendors + publisher TC (third segment) + coreString := "COyiILmOyiILmADACHENAPCAAAAAAAAAAAAAE5QBgALgAqgD8AQACSwEygJyAAAAAA" + disclosedVendorsBytes := []byte{0x20, 0x01, 0x4a, 0x80} + disclosedVendorsString := base64.RawURLEncoding.EncodeToString(disclosedVendorsBytes) + publisherTCString := "YAAAAAAAAAAA" // placeholder + + consentString := coreString + "." + disclosedVendorsString + "." + publisherTCString + + consent, err := ParseString(consentString) + assertNilError(t, err) + + // Core parsing should work + assertUInt16sEqual(t, 15, consent.VendorListVersion()) + + // HasDisclosedVendors should return true + assertBoolsEqual(t, true, consent.HasDisclosedVendors()) + + // Disclosed vendors should be parsed (ignoring publisher TC for now) + assertBoolsEqual(t, true, consent.VendorDisclosed(1)) +} + +// TestSegmentsInAnyOrder tests that segments can appear in any order (TCF spec allows this) +func TestSegmentsInAnyOrder(t *testing.T) { + coreString := "COwGVJOOwGVJOADACHENAOCAAO6as_-AAAhoAFNLAAoAAAA" + + // Disclosed vendors segment (type=1) + // 001 0000000000011010 0 10101000000000100000100000000101000010011111 + // | | | |_ bitset + // | | |__ IsRangeEncoding (0) + // | |_ maxVendorID (26) + // |_ segment type (1) + disclosedVendorsBytes := []byte{0x20, 0x03, 0x4a, 0x80, 0x20, 0x80, 0x50, 0x9f} + + disclosedVendorsString := base64.RawURLEncoding.EncodeToString(disclosedVendorsBytes) + + // Publisher TC segment (type=3) - minimal valid segment + // Binary: 011|0000000000000000|... (type=3, no publisher restrictions) + publisherTCBytes := []byte{0x60, 0x00, 0x00} + publisherTCString := base64.RawURLEncoding.EncodeToString(publisherTCBytes) + + // Test order 1: Core.Disclosed.Publisher + consent1, err := ParseString(coreString + "." + disclosedVendorsString + "." + publisherTCString) + + assertNilError(t, err) + assertBoolsEqual(t, true, consent1.HasDisclosedVendors()) + + assertUInt16sEqual(t, 26, consent1.VendorDisclosedMaxVendorId()) + + assertBoolsEqual(t, true, consent1.VendorDisclosed(1)) + assertBoolsEqual(t, true, consent1.VendorDisclosed(3)) + assertBoolsEqual(t, true, consent1.VendorDisclosed(21)) + assertBoolsEqual(t, false, consent1.VendorDisclosed(27)) // greater than vendorDisclosedMaxVendorId + + // Test order 2: Core.Publisher.Disclosed (reversed order) + consent2, err := ParseString(coreString + "." + publisherTCString + "." + disclosedVendorsString) + + // Both should have disclosed vendors + assertBoolsEqual(t, true, consent2.HasDisclosedVendors()) + assertNilError(t, err) + assertBoolsEqual(t, true, consent2.VendorDisclosed(1)) + assertBoolsEqual(t, true, consent2.VendorDisclosed(3)) + + // Both should give same results + assertBoolsEqual(t, consent1.VendorDisclosed(5), consent2.VendorDisclosed(5)) +} diff --git a/vendorconsent/tcf2/metadata.go b/vendorconsent/tcf2/metadata.go index 450f105..a8679d7 100644 --- a/vendorconsent/tcf2/metadata.go +++ b/vendorconsent/tcf2/metadata.go @@ -45,6 +45,8 @@ type ConsentMetadata struct { vendorConsents vendorConsentsResolver vendorLegitimateInterests vendorConsentsResolver publisherRestrictions pubRestrictResolver + disclosedVendors vendorConsentsResolver // TCF 2.3: Disclosed Vendors segment + hasDisclosedVendors bool // TCF 2.3: whether the Disclosed Vendors segment was present } type vendorConsentsResolver interface { @@ -146,7 +148,7 @@ func (c ConsentMetadata) VendorListVersion() uint16 { // TCFPolicyVersion returns the TCF policy version stored in bits 133 to 138 func (c ConsentMetadata) TCFPolicyVersion() uint8 { // Stored in bits 133-138.. which is [0000xxxx xx00000000] starting at the 17th byte - return uint8(((c.data[16] & 0x0f) << 2) | (c.data[17] & 0xc0) >> 6) + return uint8(((c.data[16] & 0x0f) << 2) | (c.data[17]&0xc0)>>6) } // MaxVendorID returns the maximum value for vendor identifier in bits 214 to 229 @@ -205,6 +207,29 @@ func (c ConsentMetadata) CheckPubRestriction(purposeID uint8, restrictType uint8 return c.publisherRestrictions.CheckPubRestriction(purposeID, restrictType, vendor) } +// VendorDisclosed returns true if the vendor was disclosed to the user (TCF 2.3). +// For backward compatibility with TCF 2.0/2.2 strings without disclosed vendors segment, +// returns false when no disclosed vendors data is available. +func (c ConsentMetadata) VendorDisclosed(id uint16) bool { + if c.disclosedVendors == nil { + return false + } + return c.disclosedVendors.VendorConsent(id) +} + +// VendorDisclosedMaxVendorId returns the maximum vendor ID in the disclosed vendors segment (TCF 2.3). +func (c ConsentMetadata) VendorDisclosedMaxVendorId() uint16 { + if c.disclosedVendors == nil { + return 0 + } + return c.disclosedVendors.MaxVendorID() +} + +// HasDisclosedVendors returns true if the consent string includes a disclosedVendors segment. +func (c ConsentMetadata) HasDisclosedVendors() bool { + return c.hasDisclosedVendors +} + // Returns true if the bitIndex'th bit in data is a 1, and false if it's a 0. func isSet(data []byte, bitIndex uint) bool { byteIndex := bitIndex / 8 diff --git a/vendorconsent/tcf2/metadata_test.go b/vendorconsent/tcf2/metadata_test.go index 2555b17..62ac373 100644 --- a/vendorconsent/tcf2/metadata_test.go +++ b/vendorconsent/tcf2/metadata_test.go @@ -62,50 +62,50 @@ func TestLanguageExtremes(t *testing.T) { func TestTCFPolicyVersion(t *testing.T) { baseConsent := "CPtGDMAPtGDMALMAAAENA_C_AAAAAAAAACiQAAAAAAAA" index := 22 // policy version is at the 23rd 6-bit base64 position - tests := []struct{ - name string - base64Char string - expected uint8 + tests := []struct { + name string + base64Char string + expected uint8 }{ { - name: "char_A_bits_000000_is_version_0", - base64Char: "A", - expected: 0, + name: "char_A_bits_000000_is_version_0", + base64Char: "A", + expected: 0, }, { - name: "char_B_bits_000001_is_version_1", - base64Char: "B", - expected: 1, + name: "char_B_bits_000001_is_version_1", + base64Char: "B", + expected: 1, }, { - name: "char_C_bits_000010_is_version_2", - base64Char: "C", - expected: 2, + name: "char_C_bits_000010_is_version_2", + base64Char: "C", + expected: 2, }, { - name: "char_E_bits_000100_is_version_4", - base64Char: "E", - expected: 4, + name: "char_E_bits_000100_is_version_4", + base64Char: "E", + expected: 4, }, { - name: "char_I_bits_001000_is_version_8", - base64Char: "I", - expected: 8, + name: "char_I_bits_001000_is_version_8", + base64Char: "I", + expected: 8, }, { - name: "char_Q_bits_010000_is_version_16", - base64Char: "Q", - expected: 16, + name: "char_Q_bits_010000_is_version_16", + base64Char: "Q", + expected: 16, }, { - name: "char_g_bits_100000_is_version_32", - base64Char: "g", - expected: 32, + name: "char_g_bits_100000_is_version_32", + base64Char: "g", + expected: 32, }, { - name: "char_underscore_bits_111111_is_version_63", - base64Char: "_", - expected: 63, + name: "char_underscore_bits_111111_is_version_63", + base64Char: "_", + expected: 63, }, } for _, tt := range tests { @@ -143,3 +143,41 @@ func TestLITransparency(t *testing.T) { assertBoolsEqual(t, false, consent.PurposeLITransparency(28)) } + +func TestVendorDisclosedWithoutSegment(t *testing.T) { + // TCF 2.0/2.2 consent string without disclosed vendors segment + baseConsent, err := Parse(decode(t, "COx3XOeOx3XOeLkAAAENAfCIAAAAAHgAAIAAAAAAAAAA")) + assertNilError(t, err) + consent := baseConsent.(ConsentMetadata) + + // Should return false for all vendors when no disclosed vendors segment exists + assertBoolsEqual(t, false, consent.VendorDisclosed(1)) + assertBoolsEqual(t, false, consent.VendorDisclosed(10)) + assertBoolsEqual(t, false, consent.VendorDisclosed(100)) + assertBoolsEqual(t, false, consent.VendorDisclosed(999)) +} + +func TestVendorDisclosedNilCheck(t *testing.T) { + // Parse core string directly (no disclosed vendors) + baseConsent, err := Parse(decode(t, "COx3XOeOx3XOeLkAAAENAfCIAAAAAHgAAIAAAAAAAAAA")) + assertNilError(t, err) + consent := baseConsent.(ConsentMetadata) + + // Verify disclosedVendors field is nil + if consent.disclosedVendors != nil { + t.Errorf("Expected disclosedVendors to be nil for core string without segment") + } + + // VendorDisclosed should safely return false when disclosedVendors is nil + assertBoolsEqual(t, false, consent.VendorDisclosed(1)) +} + +func TestHasDisclosedVendorsWithoutSegment(t *testing.T) { + // TCF 2.0/2.2 consent string without disclosed vendors segment + baseConsent, err := Parse(decode(t, "COx3XOeOx3XOeLkAAAENAfCIAAAAAHgAAIAAAAAAAAAA")) + assertNilError(t, err) + consent := baseConsent.(ConsentMetadata) + + // HasDisclosedVendors should return false when segment is not present + assertBoolsEqual(t, false, consent.HasDisclosedVendors()) +} diff --git a/vendorconsent/tcf2/rangesection.go b/vendorconsent/tcf2/rangesection.go index fa14353..8d003df 100644 --- a/vendorconsent/tcf2/rangesection.go +++ b/vendorconsent/tcf2/rangesection.go @@ -9,8 +9,10 @@ import ( func parseRangeSection(metadata ConsentMetadata, maxVendorID uint16, startbit uint) (*rangeSection, uint, error) { data := metadata.data - if len(data) < 31 { - return nil, 0, fmt.Errorf("vendor consent strings using RangeSections require at least 31 bytes. Got %d", len(data)) + // Check we have enough bytes to read the NumEntries field (12 bits starting at startbit) + minBytesRequired := (startbit + 12 + 7) / 8 + if uint(len(data)) < minBytesRequired { + return nil, 0, fmt.Errorf("vendor consent strings using RangeSections require at least %d bytes to read NumEntries. Got %d", minBytesRequired, len(data)) } // This makes an int from bits [startBit, startBit + 12)