Skip to content

Commit

Permalink
Add support to publisher tc (#22)
Browse files Browse the repository at this point in the history
* add code to handle publisher tc, start to implement #13

* add missing changes

* some refactor

* add example

* update pod

* add unit tests

* add unit tests

* force read the first segment as core string

* verify unit tests

* narrow unit test

* narrow unit test 2

* continue search

* fix unit test
  • Loading branch information
peczenyj authored Dec 15, 2023
1 parent 966a954 commit e81ff47
Show file tree
Hide file tree
Showing 11 changed files with 968 additions and 140 deletions.
1 change: 1 addition & 0 deletions Changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- parse publisher tc section if available
- add strict mode (disabled by default) to validate the consent string version

0.084
Expand Down
27 changes: 26 additions & 1 deletion README.pod
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,12 @@ For the avoidance of doubt:

In case a vendor has declared flexibility for a purpose and there is no legal basis restriction signal it must always apply the default legal basis under which the purpose was registered aside from being registered as flexible. That means if a vendor declared a purpose as legitimate interest and also declared that purpose as flexible it may not apply a "consent" signal without a legal basis restriction signal to require consent.

=head2 publisher_tc

If the consent string has a C<Publisher TC> section, we will decode this section as an instance of L<GDPR::IAB::TCFv2::PublisherTC>.

Will return undefined if there is no C<Publisher TC> section.

=head2 TO_JSON

Will serialize the consent object into a hash reference. The objective is to be used by L<JSON> package.
Expand All @@ -356,7 +362,7 @@ With option C<convert_blessed>, the encoder will call this method.
use GDPR::IAB::TCFv2;

my $consent = GDPR::IAB::TCFv2->Parse(
'COyiILmOyiILmADACHENAPCAAAAAAAAAAAAAE5QBgALgAqgD8AQACSwEygJyAAAAAA',
'COyiILmOyiILmADACHENAPCAAAAAAAAAAAAAE5QBgALgAqgD8AQACSwEygJyAAAAAA.argAC0gAAAAAAAAAAAA',
json => {
compact => 1,
date_format => sub { # can be omitted, with DateTimeX::TO_JSON
Expand Down Expand Up @@ -385,6 +391,25 @@ Outputs:
"cmp_id" : 3,
"purpose_one_treatment" : false,
"publisher" : {
"consents" : [
2,
4,
6,
8,
9,
10
],
"legitimate_interests" : [
2,
4,
5,
7,
10
],
"custom_purpose" : {
"consents" : [],
"legitimate_interests" : []
},
"restrictions" : {}
},
"special_features_opt_in" : [],
Expand Down
145 changes: 83 additions & 62 deletions lib/GDPR/IAB/TCFv2.pm
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use GDPR::IAB::TCFv2::BitUtils qw<is_set
get_uint36
get_char6_pair
>;
use GDPR::IAB::TCFv2::PublisherRestrictions;
use GDPR::IAB::TCFv2::Publisher;
use GDPR::IAB::TCFv2::RangeSection;

our $VERSION = "0.084";
Expand All @@ -31,7 +31,6 @@ use constant {
MIN_BYTE_SIZE => 29,
},
EXPECTED_TCF_V2_VERSION => 2,
ASSUMED_MAX_VENDOR_ID => 0x7FFF, # 32767 or (1 << 15) -1
MAX_SPECIAL_FEATURE_ID => 12,
MAX_PURPOSE_ID => 24,
DATE_FORMAT_ISO_8601 => '%Y-%m-%dT%H:%M:%SZ',
Expand Down Expand Up @@ -117,7 +116,7 @@ sub Parse {

vendor_consents => undef,
vendor_legitimate_interests => undef,
publisher_restrictions => undef,
publisher => undef,
};

bless $self, $klass;
Expand Down Expand Up @@ -332,8 +331,14 @@ sub vendor_legitimate_interest {
sub check_publisher_restriction {
my ( $self, $purpose_id, $restrict_type, $vendor ) = @_;

return $self->{publisher_restrictions}
->contains( $purpose_id, $restrict_type, $vendor );
return $self->{publisher}
->check_restriction( $purpose_id, $restrict_type, $vendor );
}

sub publisher_tc {
my $self = shift;

return $self->{publisher}->publisher_tc;
}

sub _format_date {
Expand Down Expand Up @@ -422,16 +427,22 @@ sub TO_JSON {
legitimate_interests =>
$self->{vendor_legitimate_interests}->TO_JSON,
},
publisher => {
restrictions => $self->{publisher_restrictions}->TO_JSON,
},
publisher => $self->{publisher}->TO_JSON,
};
}

sub _decode_tc_string_segments {
my $tc_string = shift;

my (@parts) = split CONSENT_STRING_TCF_V2->{SEPARATOR}, $tc_string;
my ( $core, @parts ) = split CONSENT_STRING_TCF_V2->{SEPARATOR},
$tc_string;

my $core_data = _validate_and_decode_base64($core);
my $core_data_size = length($core_data) / 8;

croak
"vendor consent strings are at least @{[ CONSENT_STRING_TCF_V2->{MIN_BYTE_SIZE} ]} bytes long (got ${core_data_size} bytes)"
if $core_data_size < CONSENT_STRING_TCF_V2->{MIN_BYTE_SIZE};

my %segments;

Expand All @@ -443,19 +454,9 @@ sub _decode_tc_string_segments {
$segments{$segment_type} = $decoded;
}

croak "missing core section"
unless exists $segments{ SEGMENT_TYPES->{CORE} };

my $core_data = $segments{ SEGMENT_TYPES->{CORE} };
my $disclosed_vendors = $segments{ SEGMENT_TYPES->{DISCLOSED_VENDORS} };
my $publisher_tc = $segments{ SEGMENT_TYPES->{PUBLISHER_TC} };

my $core_data_size = length($core_data) / 8;

croak
"vendor consent strings are at least @{[ CONSENT_STRING_TCF_V2->{MIN_BYTE_SIZE} ]} bytes long (got ${core_data_size} bytes)"
if $core_data_size < CONSENT_STRING_TCF_V2->{MIN_BYTE_SIZE};

# return hashref
return {
core_data => $core_data,
Expand Down Expand Up @@ -531,75 +532,70 @@ sub _parse_vendor_legitimate_interests {
return $pub_restrict_offset;
}

sub _parse_publisher_section {
my ( $self, $pub_restrict_offset ) = @_;

# parse public restrictions

my $core_data = substr( $self->{core_data}, $pub_restrict_offset );
my $core_data_size = length( $self->{core_data} );

my $publisher = GDPR::IAB::TCFv2::Publisher->Parse(
core_data => $core_data,
core_data_size => $core_data_size,
publisher_tc_data => $self->{publisher_tc_data},
options => $self->{options},
);

$self->{publisher} = $publisher;
}

sub _parse_disclosed_vendors {
my $self = shift;

# TODO parse section disclosed vendors if available

return unless defined $self->{disclosed_vendors_data}; # if avaliable

# my $disclosed_vendors = $self->_parse_bitfield_or_range(0, 'disclosed_vendors_data');

# $self->{disclosed_vendors} = $disclosed_vendors;
}

sub _parse_bitfield_or_range {
my ( $self, $offset ) = @_;
my ( $self, $offset, $section ) = @_;

$section ||= q<core_data>;

my $something;

my ( $max_id, $next_offset ) = get_uint16( $self->{core_data}, $offset );
my ( $max_id, $next_offset ) = get_uint16( $self->{$section}, $offset );

my $is_range;

( $is_range, $next_offset ) = is_set(
$self->{core_data},
$self->{$section},
$next_offset,
);

if ($is_range) {
( $something, $next_offset ) = $self->_parse_range_section(
$max_id,
$next_offset,
$section,
);
}
else {
( $something, $next_offset ) = $self->_parse_bitfield(
$max_id,
$next_offset,
$section,
);
}

return wantarray ? ( $something, $next_offset ) : $something;
}

sub _parse_publisher_section {
my ( $self, $pub_restrict_offset ) = @_;

$self->_parse_publisher_restrictions($pub_restrict_offset);

# TODO parse section publisher_tc if available

# $self->{publisher_tc_data}; # if avaliable
}

sub _parse_publisher_restrictions {
my ( $self, $pub_restrict_offset ) = @_;

my $data = substr(
$self->{core_data}, $pub_restrict_offset,
ASSUMED_MAX_VENDOR_ID
);

my ( $publisher_restrictions, $relative_next_offset ) =
GDPR::IAB::TCFv2::PublisherRestrictions->Parse(
data => $data,
data_size => length( $self->{core_data} ),
max_id => ASSUMED_MAX_VENDOR_ID,
options => $self->{options},
);

$self->{publisher_restrictions} = $publisher_restrictions;

return $pub_restrict_offset + $relative_next_offset;
}

sub _parse_disclosed_vendors {
my $self = shift;

# TODO parse section disclosed vendors if available

# $self->{disclosed_vendors_data}; # if avaliable
}

sub _parse_range_section {
my ( $self, $max_id, $range_section_start_offset, $section ) = @_;

Expand Down Expand Up @@ -995,6 +991,12 @@ For the avoidance of doubt:
In case a vendor has declared flexibility for a purpose and there is no legal basis restriction signal it must always apply the default legal basis under which the purpose was registered aside from being registered as flexible. That means if a vendor declared a purpose as legitimate interest and also declared that purpose as flexible it may not apply a "consent" signal without a legal basis restriction signal to require consent.
=head2 publisher_tc
If the consent string has a C<Publisher TC> section, we will decode this section as an instance of L<GDPR::IAB::TCFv2::PublisherTC>.
Will return undefined if there is no C<Publisher TC> section.
=head2 TO_JSON
Will serialize the consent object into a hash reference. The objective is to be used by L<JSON> package.
Expand All @@ -1011,7 +1013,7 @@ With option C<convert_blessed>, the encoder will call this method.
use GDPR::IAB::TCFv2;
my $consent = GDPR::IAB::TCFv2->Parse(
'COyiILmOyiILmADACHENAPCAAAAAAAAAAAAAE5QBgALgAqgD8AQACSwEygJyAAAAAA',
'COyiILmOyiILmADACHENAPCAAAAAAAAAAAAAE5QBgALgAqgD8AQACSwEygJyAAAAAA.argAC0gAAAAAAAAAAAA',
json => {
compact => 1,
date_format => sub { # can be omitted, with DateTimeX::TO_JSON
Expand Down Expand Up @@ -1040,6 +1042,25 @@ Outputs:
"cmp_id" : 3,
"purpose_one_treatment" : false,
"publisher" : {
"consents" : [
2,
4,
6,
8,
9,
10
],
"legitimate_interests" : [
2,
4,
5,
7,
10
],
"custom_purpose" : {
"consents" : [],
"legitimate_interests" : []
},
"restrictions" : {}
},
"special_features_opt_in" : [],
Expand Down
60 changes: 33 additions & 27 deletions lib/GDPR/IAB/TCFv2/BitField.pm
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ use Carp qw<croak>;
sub Parse {
my ( $klass, %args ) = @_;

croak "missing 'data'" unless defined $args{data};
croak "missing 'data'" unless defined $args{data};
croak "missing 'data_size'" unless defined $args{data_size};
croak "missing 'max_id'"
unless defined $args{max_id};

Expand Down Expand Up @@ -80,24 +81,6 @@ sub TO_JSON {
};
}

sub _format_json_subsection2 {
my ( $self, $data, $max ) = @_;

my ( $false, $true ) = @{ $self->{options}->{json}->{boolean_values} };

if ( !!$self->{options}->{json}->{compact} ) {
return [
grep { $data->{$_} } 1 .. $max,
];
}

my $verbose = !!$self->{options}->{json}->{verbose};

return $data if $verbose;

return { map { $_ => $true } grep { $data->{$_} } keys %{$data} };
}

1;
__END__
Expand All @@ -112,22 +95,37 @@ GDPR::IAB::TCFv2::BitField - Transparency & Consent String version 2 bitfield pa
my $max_id_consent = << get 16 bits from $data offset 213 >>
my $bit_field = GDPR::IAB::TCFv2::BitField->Parse(
data => $data,
offset => 230, # offset for vendor consents
max_id => $max_id_consent,
data => substr($data, OFFSET),
data_size => length($data),
max_id => $max_id_consent,
options => { json => ... },
);
if $bit_field->contains(284) { ... }
say "bit field contains id 284" if $bit_field->contains(284);
=head1 CONSTRUCTOR
Constructor C<Parse> receive 3 parameters: data (as sequence of bits), start bit offset and vendor bits required (max vendor id).
Constructor C<Parse> receives an hash of 4 parameters:
=over
Will die if any parameter is missing.
=item *
Will die if data does not contain all bits required.
Key C<data> is the binary data
Will return an array of two elements: the object itself and the next offset.
=item *
Key C<data_size> is the original binary data size
=item *
Key C<max_id> is the max id (used to validate the ranges if all data is between 1 and C<max_id>)
=item *
Key C<options> is the L<GDPR::IAB::TCFv2> options (includes the C<json> field to modify the L</TO_JSON> method output.
=back
=head1 METHODS
Expand All @@ -145,3 +143,11 @@ Returns the max vendor id.
=head2 all
Returns an array of all vendors mapped with the bit enabled.
=head2 TO_JSON
By default it returns an hashref mapping id to a boolean, that represent if the id is active or not in the bitfield.
The json option C<verbose> controls if all ids between 1 to L</max_id> will be present on the C<json> or only the ones that are true.
The json option C<compact> change the response, will return an arrayref of all ids active on the bitfield.
Loading

0 comments on commit e81ff47

Please sign in to comment.