File Coverage

blib/lib/GDPR/IAB/TCFv2.pm
Criterion Covered Total %
statement 225 232 96.9
branch 50 66 75.7
condition 11 18 61.1
subroutine 62 63 98.4
pod 27 27 100.0
total 375 406 92.3


line stmt bran cond sub pod time code
1             package GDPR::IAB::TCFv2;
2              
3 5     5   576069 use strict;
  5         14  
  5         222  
4 5     5   53 use warnings;
  5         10  
  5         375  
5 5     5   2587 use integer;
  5         106  
  5         31  
6 5     5   2179 use bytes;
  5         2794  
  5         40  
7              
8 5     5   237 use Carp qw;
  5         11  
  5         453  
9 5     5   3373 use MIME::Base64 qw;
  5         5068  
  5         532  
10 5     5   3102 use POSIX qw;
  5         56983  
  5         43  
11              
12 5     5   14041 use GDPR::IAB::TCFv2::BitField;
  5         39  
  5         300  
13 5         477 use GDPR::IAB::TCFv2::BitUtils qw
14             get_uint2
15             get_uint3
16             get_uint6
17             get_uint12
18             get_uint16
19             get_uint36
20             get_char6_pair
21 5     5   38 >;
  5         11  
22 5     5   3333 use GDPR::IAB::TCFv2::Publisher;
  5         19  
  5         209  
23 5     5   2201 use GDPR::IAB::TCFv2::RangeSection;
  5         129  
  5         738  
24              
25             our $VERSION = "0.203";
26              
27             use constant {
28 5         993 CONSENT_STRING_TCF_V2 => {
29             SEPARATOR => quotemeta q<.>,
30             PREFIX => q,
31             MIN_BYTE_SIZE => 29,
32             },
33             EXPECTED_TCF_V2_VERSION => 2,
34             MAX_SPECIAL_FEATURE_ID => 12,
35             MAX_PURPOSE_ID => 24,
36             DATE_FORMAT_ISO_8601 => '%Y-%m-%dT%H:%M:%SZ',
37             SEGMENT_TYPES => {
38             CORE => 0,
39             PUBLISHER_TC => 3,
40             },
41             OFFSETS => {
42             SEGMENT_TYPE => 0,
43             VERSION => 0,
44             CREATED => 6,
45             LAST_UPDATED => 42,
46             CMP_ID => 78,
47             CMP_VERSION => 90,
48             CONSENT_SCREEN => 102,
49             CONSENT_LANGUAGE => 108,
50             VENDOR_LIST_VERSION => 120,
51             POLICY_VERSION => 132,
52             SERVICE_SPECIFIC => 138,
53             USE_NON_STANDARD_STACKS => 139,
54             SPECIAL_FEATURE_OPT_IN => 140,
55             PURPOSE_CONSENT_ALLOWED => 152,
56             PURPOSE_LIT_ALLOWED => 176,
57             PURPOSE_ONE_TREATMENT => 200,
58             PUBLISHER_COUNTRY_CODE => 201,
59             VENDOR_CONSENT => 213,
60             },
61 5     5   43 };
  5         11  
62              
63 5     5   39 use overload q<""> => \&tc_string;
  5         11  
  5         81  
64              
65             # ABSTRACT: gdpr iab tcf v2 consent string parser
66              
67             sub Parse {
68 35     35 1 1817549 my ( $klass, $tc_string, %opts ) = @_;
69              
70 35 100       162 croak 'missing gdpr consent string' unless $tc_string;
71              
72 33         121 my $segments = _decode_tc_string_segments($tc_string);
73              
74 31         90 my $strict = !!$opts{strict};
75              
76             my %options = (
77             json => $opts{json} || {},
78 31   100     240 strict => $strict,
79             );
80              
81 31   100     228 $options{json}->{date_format} ||= DATE_FORMAT_ISO_8601;
82 31   100     174 $options{json}->{boolean_values} ||= [ _json_false(), _json_true() ];
83              
84 31 100       117 if ( exists $opts{prefetch} ) {
85 1         1 my $prefetch = $opts{prefetch};
86              
87 1 50       5 $prefetch = [$prefetch] if ref($prefetch) ne ref( [] );
88              
89 1         2 $options{prefetch} = $prefetch;
90             }
91              
92             my $self = {
93             core_data => $segments->{core_data},
94             publisher_tc_data => $segments->{publisher_tc},
95 31         295 options => \%options,
96             tc_string => $tc_string,
97              
98             vendor_consents => undef,
99             vendor_legitimate_interests => undef,
100             publisher => undef,
101             };
102              
103 31         81 bless $self, $klass;
104              
105 31 100 66     104 croak "consent string is not tcf version @{[ EXPECTED_TCF_V2_VERSION ]}"
  2         37  
106             if $strict && $self->version != EXPECTED_TCF_V2_VERSION;
107              
108 29 50       97 croak 'invalid vendor list version' if $self->vendor_list_version == 0;
109              
110 29         109 my $next_offset = $self->_parse_vendor_section();
111              
112 27         108 $self->_parse_publisher_section($next_offset);
113              
114 27         154 return $self;
115             }
116              
117             sub tc_string {
118 28     28 1 5479 my $self = shift;
119              
120 28         186 return $self->{tc_string};
121             }
122              
123             sub version {
124 30     30 1 3927 my $self = shift;
125              
126 30         129 return scalar( get_uint6( $self->{core_data}, OFFSETS->{VERSION} ) );
127             }
128              
129             sub created {
130 22     22 1 52 my $self = shift;
131              
132 22         116 my ( $seconds, $nanoseconds ) = $self->_get_epoch( OFFSETS->{CREATED} );
133              
134 22 100       106 return wantarray ? ( $seconds, $nanoseconds ) : $seconds;
135             }
136              
137             sub last_updated {
138 22     22 1 5334 my $self = shift;
139              
140             my ( $seconds, $nanoseconds ) =
141 22         83 $self->_get_epoch( OFFSETS->{LAST_UPDATED} );
142              
143 22 100       88 return wantarray ? ( $seconds, $nanoseconds ) : $seconds;
144             }
145              
146             sub _get_epoch {
147 44     44   109 my ( $self, $offset ) = @_;
148              
149 44         150 my $deciseconds = scalar( get_uint36( $self->{core_data}, $offset ) );
150              
151             return (
152 44         112 ( $deciseconds / 10 ),
153             ( ( $deciseconds % 10 ) * 100_000_000 ),
154             );
155             }
156              
157             sub cmp_id {
158 20     20 1 3494 my $self = shift;
159              
160 20         67 return scalar( get_uint12( $self->{core_data}, OFFSETS->{CMP_ID} ) );
161             }
162              
163             sub cmp_version {
164 20     20 1 94 my $self = shift;
165              
166 20         96 return scalar( get_uint12( $self->{core_data}, OFFSETS->{CMP_VERSION} ) );
167             }
168              
169             sub consent_screen {
170 20     20 1 32 my $self = shift;
171              
172             return
173 20         67 scalar( get_uint6( $self->{core_data}, OFFSETS->{CONSENT_SCREEN} ) );
174             }
175              
176             sub consent_language {
177 20     20 1 35 my $self = shift;
178              
179             return
180             scalar(
181 20         80 get_char6_pair( $self->{core_data}, OFFSETS->{CONSENT_LANGUAGE} ) );
182             }
183              
184             sub vendor_list_version {
185 49     49 1 82 my $self = shift;
186              
187             return
188             scalar(
189 49         377 get_uint12( $self->{core_data}, OFFSETS->{VENDOR_LIST_VERSION} ) );
190             }
191              
192             sub policy_version {
193 20     20 1 57 my $self = shift;
194              
195             return
196 20         66 scalar( get_uint6( $self->{core_data}, OFFSETS->{POLICY_VERSION} ) );
197             }
198              
199             sub is_service_specific {
200 20     20 1 32 my $self = shift;
201              
202 20         62 return scalar( is_set( $self->{core_data}, OFFSETS->{SERVICE_SPECIFIC} ) );
203             }
204              
205             sub use_non_standard_stacks {
206 20     20 1 32 my $self = shift;
207              
208             return
209             scalar(
210 20         74 is_set( $self->{core_data}, OFFSETS->{USE_NON_STANDARD_STACKS} ) );
211             }
212              
213             sub is_special_feature_opt_in {
214 25     25 1 27413 my ( $self, $id ) = @_;
215              
216 25 50 33     158 croak
217 0         0 "invalid special feature id $id: must be between 1 and @{[ MAX_SPECIAL_FEATURE_ID ]}"
218             if $id < 1 || $id > MAX_SPECIAL_FEATURE_ID;
219              
220 25         71 return $self->_safe_is_special_feature_opt_in($id);
221             }
222              
223             sub _safe_is_special_feature_opt_in {
224 241     241   393 my ( $self, $id ) = @_;
225              
226             return scalar(
227             is_set(
228 241         564 $self->{core_data}, OFFSETS->{SPECIAL_FEATURE_OPT_IN} + $id - 1
229             )
230             );
231             }
232              
233             sub is_purpose_consent_allowed {
234 48     48 1 51109 my ( $self, $id ) = @_;
235              
236 48 50 33     267 croak "invalid purpose id $id: must be between 1 and @{[ MAX_PURPOSE_ID ]}"
  0         0  
237             if $id < 1 || $id > MAX_PURPOSE_ID;
238              
239 48         156 return $self->_safe_is_purpose_consent_allowed($id);
240             }
241              
242             sub _safe_is_purpose_consent_allowed {
243 480     480   692 my ( $self, $id ) = @_;
244             return scalar(
245             is_set(
246 480         1154 $self->{core_data}, OFFSETS->{PURPOSE_CONSENT_ALLOWED} + $id - 1
247             )
248             );
249             }
250              
251             sub is_purpose_legitimate_interest_allowed {
252 48     48 1 45985 my ( $self, $id ) = @_;
253              
254 48 50 33     301 croak "invalid purpose id $id: must be between 1 and @{[ MAX_PURPOSE_ID ]}"
  0         0  
255             if $id < 1 || $id > MAX_PURPOSE_ID;
256              
257 48         129 return $self->_safe_is_purpose_legitimate_interest_allowed($id);
258             }
259              
260             sub _safe_is_purpose_legitimate_interest_allowed {
261 480     480   701 my ( $self, $id ) = @_;
262              
263             return
264             scalar(
265 480         1052 is_set( $self->{core_data}, OFFSETS->{PURPOSE_LIT_ALLOWED} + $id - 1 )
266             );
267             }
268              
269             sub purpose_one_treatment {
270 21     21 1 32 my $self = shift;
271              
272             return
273 21         73 scalar( is_set( $self->{core_data}, OFFSETS->{PURPOSE_ONE_TREATMENT} ) );
274             }
275              
276             sub publisher_country_code {
277 20     20 1 31 my $self = shift;
278              
279             return scalar(
280             get_char6_pair(
281             $self->{core_data}, OFFSETS->{PUBLISHER_COUNTRY_CODE}
282             )
283 20         57 );
284             }
285              
286             sub max_vendor_id_consent {
287 2     2 1 6 my $self = shift;
288              
289 2         15 return $self->{vendor_consents}->max_id;
290             }
291              
292             sub max_vendor_id_legitimate_interest {
293 2     2 1 5 my $self = shift;
294              
295 2         11 return $self->{vendor_legitimate_interests}->max_id;
296             }
297              
298             sub vendor_consent {
299 1376     1376 1 1677464 my ( $self, $id ) = @_;
300              
301 1376         7574 return $self->{vendor_consents}->contains($id);
302             }
303              
304             sub vendor_legitimate_interest {
305 1376     1376 1 1460355 my ( $self, $id ) = @_;
306              
307 1376         7922 return $self->{vendor_legitimate_interests}->contains($id);
308             }
309              
310             sub check_publisher_restriction {
311 19     19 1 60 my $self = shift;
312              
313 19         48 my ( $purpose_id, $restriction_type, $vendor_id );
314              
315 19 100       105 if ( scalar(@_) == 6 ) {
316 1         6 my (%opts) = @_;
317              
318 1         3 $purpose_id = $opts{purpose_id};
319 1         3 $restriction_type = $opts{restriction_type};
320 1         3 $vendor_id = $opts{vendor_id};
321             }
322              
323 19         54 ( $purpose_id, $restriction_type, $vendor_id ) = @_;
324              
325             return $self->{publisher}
326 19         118 ->check_restriction( $purpose_id, $restriction_type, $vendor_id );
327             }
328              
329             sub publisher_restrictions {
330 8     8 1 2044 my ( $self, $vendor_id ) = @_;
331              
332 8         51 return $self->{publisher}->restrictions($vendor_id);
333             }
334              
335             sub publisher_tc {
336 4     4 1 1840 my $self = shift;
337              
338 4         24 return $self->{publisher}->publisher_tc;
339             }
340              
341             sub _format_date {
342 36     36   71 my ( $self, $epoch, $nanoseconds ) = @_;
343              
344 36 100       116 return $epoch if !!$self->{options}->{json}->{use_epoch};
345              
346 32         82 my $format = $self->{options}->{json}->{date_format};
347              
348 32 50   0   172 return $format->( $epoch, $nanoseconds ) if ref($format) eq ref( sub { } );
349              
350 32         1092 return strftime( $format, gmtime($epoch) );
351             }
352              
353 15     15   63 sub _json_true { 1 == 1 }
354              
355 15     15   95 sub _json_false { 1 == 0 }
356              
357             sub _format_json_subsection {
358 54     54   158 my ( $self, @data ) = @_;
359              
360 54 100       154 if ( !!$self->{options}->{json}->{compact} ) {
361 42         79 return [ map { $_->[0] } grep { $_->[1] } @data ];
  200         414  
  840         1159  
362             }
363              
364 12         34 my $verbose = !!$self->{options}->{json}->{verbose};
365              
366 12 100       28 return { map { @{$_} } grep { $verbose || $_->[1] } @data };
  131         142  
  131         386  
  240         595  
367             }
368              
369             sub TO_JSON {
370 18     18 1 126 my $self = shift;
371              
372 18         28 my ( $false, $true ) = @{ $self->{options}->{json}->{boolean_values} };
  18         59  
373              
374 18         53 my $created = $self->_format_date( $self->created );
375 18         69 my $last_updated = $self->_format_date( $self->last_updated );
376              
377             return {
378             tc_string => $self->tc_string,
379             version => $self->version,
380             created => $created,
381             last_updated => $last_updated,
382             cmp_id => $self->cmp_id,
383             cmp_version => $self->cmp_version,
384             consent_screen => $self->consent_screen,
385             consent_language => $self->consent_language,
386             vendor_list_version => $self->vendor_list_version,
387             policy_version => $self->policy_version,
388             is_service_specific => $self->is_service_specific ? $true : $false,
389             use_non_standard_stacks => $self->use_non_standard_stacks
390             ? $true
391             : $false,
392             purpose_one_treatment => $self->purpose_one_treatment ? $true : $false,
393             publisher_country_code => $self->publisher_country_code,
394             special_features_opt_in => $self->_format_json_subsection(
395             map {
396 216 100       358 [ $_ => $self->_safe_is_special_feature_opt_in($_)
397             ? $true
398             : $false
399             ]
400             } 1 .. MAX_SPECIAL_FEATURE_ID
401             ),
402             purpose => {
403             consents => $self->_format_json_subsection(
404             map {
405 432 100       737 [ $_ => $self->_safe_is_purpose_consent_allowed($_)
406             ? $true
407             : $false
408             ]
409             } 1 .. MAX_PURPOSE_ID,
410             ),
411             legitimate_interests => $self->_format_json_subsection(
412             map {
413 432 100       714 [ $_ =>
414             $self->_safe_is_purpose_legitimate_interest_allowed(
415             $_)
416             ? $true
417             : $false
418             ]
419             } 1 .. MAX_PURPOSE_ID,
420             ),
421             },
422             vendor => {
423             consents => $self->{vendor_consents}->TO_JSON,
424             legitimate_interests =>
425             $self->{vendor_legitimate_interests}->TO_JSON,
426             },
427             publisher => $self->{publisher}->TO_JSON,
428 18 100       58 };
    50          
    50          
429             }
430              
431             sub _decode_tc_string_segments {
432 33     33   104 my $tc_string = shift;
433              
434             my ( $core, @parts ) = split CONSENT_STRING_TCF_V2->{SEPARATOR},
435 33         787 $tc_string;
436              
437 33         135 my $core_data = _validate_and_decode_base64($core);
438 32         734 my $core_data_size = length($core_data) / 8;
439              
440             croak
441 1         21 "vendor consent strings are at least @{[ CONSENT_STRING_TCF_V2->{MIN_BYTE_SIZE} ]} bytes long (got ${core_data_size} bytes)"
442 32 100       152 if $core_data_size < CONSENT_STRING_TCF_V2->{MIN_BYTE_SIZE};
443              
444 31         52 my %segments;
445              
446 31         78 foreach my $part (@parts) {
447 7         20 my $decoded = _validate_and_decode_base64($part);
448              
449 7         96 my $segment_type = get_uint3( $decoded, OFFSETS->{SEGMENT_TYPE} );
450              
451 7         32 $segments{$segment_type} = $decoded;
452             }
453              
454 31         107 my $publisher_tc = $segments{ SEGMENT_TYPES->{PUBLISHER_TC} };
455              
456             return {
457 31         158 core_data => $core_data,
458             publisher_tc => $publisher_tc,
459             };
460             }
461              
462             sub _validate_and_decode_base64 {
463 40     40   92 my $s = shift;
464              
465             # see: https://www.perlmonks.org/?node_id=775820
466 40 100       430 croak "invalid base64 format" unless $s =~ m{
467             ^
468             (?: [A-Za-z0-9-_]{4} )*
469             (?:
470             [A-Za-z0-9-_]{2} [AEIMQUYcgkosw048]
471             |
472             [A-Za-z0-9-_] [AQgw]
473             )?
474             \z
475             }x;
476              
477 39         182 return unpack 'B*', _decode_base64url($s);
478             }
479              
480             sub _decode_base64url {
481             my $s = shift;
482             $s =~ tr[-_][+/];
483             $s .= '=' while length($s) % 4;
484             return decode_base64($s);
485             }
486              
487             sub _parse_vendor_section {
488 29     29   64 my $self = shift;
489              
490             # parse vendor consent
491              
492             my $legitimate_interest_offset =
493 29         104 $self->_parse_vendor_consents( OFFSETS->{VENDOR_CONSENT} );
494              
495             # parse vendor legitimate interest
496              
497 29         100 my $pub_restriction_offset =
498             $self->_parse_vendor_legitimate_interests($legitimate_interest_offset);
499              
500 27         61 return $pub_restriction_offset;
501             }
502              
503             sub _parse_vendor_consents {
504 29     29   67 my ( $self, $vendor_consent_offset ) = @_;
505              
506 29         112 my ( $vendor_consents, $legitimate_interest_offset ) =
507             $self->_parse_bitfield_or_range(
508             $vendor_consent_offset,
509             );
510              
511 29         95 $self->{vendor_consents} = $vendor_consents;
512              
513 29         77 return $legitimate_interest_offset;
514             }
515              
516             sub _parse_vendor_legitimate_interests {
517 29     29   101 my ( $self, $legitimate_interest_offset ) = @_;
518              
519 29         65 my $data_size = length( $self->{core_data} );
520              
521             my ( $vendor_legitimate_interests, $pub_restriction_offset ) =
522             $self->_parse_bitfield_or_range(
523             $legitimate_interest_offset,
524             sub {
525 29     29   52 my $legitimate_interest_start = shift;
526              
527 29 100       112 croak "invalid consent data: no legitimate interest start position"
528             if $legitimate_interest_start >= $data_size;
529             }
530 29         187 );
531              
532 27         142 $self->{vendor_legitimate_interests} = $vendor_legitimate_interests;
533              
534 27         77 return $pub_restriction_offset;
535             }
536              
537             sub _parse_publisher_section {
538 27     27   60 my ( $self, $pub_restriction_offset ) = @_;
539              
540             # parse public restrictions
541              
542 27         92 my $core_data = substr( $self->{core_data}, $pub_restriction_offset );
543 27         72 my $core_data_size = length( $self->{core_data} );
544              
545             my $publisher = GDPR::IAB::TCFv2::Publisher->Parse(
546             core_data => $core_data,
547             core_data_size => $core_data_size,
548             publisher_tc_data => $self->{publisher_tc_data},
549             options => $self->{options},
550 27         196 );
551              
552 27         74 $self->{publisher} = $publisher;
553             }
554              
555             sub _parse_bitfield_or_range {
556 58     58   139 my ( $self, $offset, $validate_next_offset ) = @_;
557              
558 58         86 my $something;
559              
560 58         183 my ( $max_id, $next_offset ) = get_uint16( $self->{core_data}, $offset );
561              
562 58 100       184 $validate_next_offset->($next_offset) if defined $validate_next_offset;
563              
564 56         79 my $is_range;
565              
566             ( $is_range, $next_offset ) = is_set(
567             $self->{core_data},
568 56         201 $next_offset,
569             );
570              
571 56 100       141 if ($is_range) {
572 17         65 ( $something, $next_offset ) = $self->_parse_range_section(
573             $max_id,
574             $next_offset,
575             );
576             }
577             else {
578 39         126 ( $something, $next_offset ) = $self->_parse_bitfield(
579             $max_id,
580             $next_offset,
581             );
582             }
583              
584 56 50       195 return wantarray ? ( $something, $next_offset ) : $something;
585             }
586              
587             sub _parse_range_section {
588 17     17   37 my ( $self, $max_id, $range_section_start_offset ) = @_;
589              
590 17         59 my $data = substr( $self->{core_data}, $range_section_start_offset );
591 17         56 my $data_size = length( $self->{core_data} );
592              
593             my ( $range_section, $next_offset ) =
594             GDPR::IAB::TCFv2::RangeSection->Parse(
595             data => $data,
596             data_size => $data_size,
597             offset => 0,
598             max_id => $max_id,
599             options => $self->{options},
600 17         98 );
601              
602             return
603             wantarray
604 17 50       63 ? ( $range_section, $range_section_start_offset + $next_offset )
605             : $range_section;
606             }
607              
608             sub _parse_bitfield {
609 39     39   77 my ( $self, $max_id, $bitfield_start_offset ) = @_;
610              
611 39         116 my $data = substr( $self->{core_data}, $bitfield_start_offset, $max_id );
612 39         76 my $data_size = length( $self->{core_data} );
613              
614             my ( $bitfield, $next_offset ) = GDPR::IAB::TCFv2::BitField->Parse(
615             data => $data,
616             data_size => $data_size,
617             max_id => $max_id,
618             options => $self->{options},
619 39         213 );
620              
621             return wantarray
622 39 50       153 ? ( $bitfield, $bitfield_start_offset + $next_offset )
623             : $bitfield;
624             }
625              
626             sub looksLikeIsConsentVersion2 {
627 5     5 1 6966 my ($gdpr_consent_string) = @_;
628              
629 5 100       23 return unless defined $gdpr_consent_string;
630              
631             return
632 4         31 rindex( $gdpr_consent_string, CONSENT_STRING_TCF_V2->{PREFIX}, 0 ) == 0;
633             }
634              
635             BEGIN {
636 5 50   5   107 if ( my $native_decode_base64url = MIME::Base64->can("decode_base64url") )
637             {
638 5     5   21936 no warnings q;
  5         85  
  5         911  
639              
640 5         68 *_decode_base64url = $native_decode_base64url;
641             }
642              
643 5         28 eval {
644 5         1559 require JSON;
645              
646 0 0       0 if ( my $native_json_true = JSON->can("true") ) {
647 5     5   37 no warnings q;
  5         10  
  5         561  
648              
649 0         0 *_json_true = $native_json_true;
650             }
651              
652 0 0       0 if ( my $native_json_false = JSON->can("false") ) {
653 5     5   35 no warnings q;
  5         28  
  5         341  
654              
655 0         0 *_json_false = $native_json_false;
656             }
657             };
658             }
659              
660              
661             1;
662             __END__