File Coverage

blib/lib/Time/TZif.pm
Criterion Covered Total %
statement 34 236 14.4
branch 0 156 0.0
condition 0 57 0.0
subroutine 11 33 33.3
pod 14 14 100.0
total 59 496 11.9


line stmt bran cond sub pod time code
1             package Time::TZif;
2 1     1   143393 use strict;
  1         1  
  1         38  
3 1     1   4 use warnings;
  1         2  
  1         42  
4 1     1   9 use v5.10;
  1         3  
5              
6             our $VERSION = '0.91';
7              
8 1     1   5 use Carp qw[croak];
  1         1  
  1         63  
9 1         62 use Time::Str::Util qw[range_bounds
10             upper_bound
11 1     1   652 valid_tzdb_timezone];
  1         2  
12 1     1   806 use Time::TZif::POSIX qw[];
  1         3  
  1         45  
13              
14             my %ValidPolicy = (
15             earlier => 1, later => 1, std => 1, dst => 1, reject => 1
16             );
17              
18 1     1   5 use constant TZIF_MAGIC => 0x545A6966;
  1         1  
  1         48  
19 1     1   3 use constant TZIF_MAX_TIMES => 2400;
  1         1  
  1         31  
20 1     1   3 use constant TZIF_MAX_TYPES => 256;
  1         1  
  1         26  
21 1     1   3 use constant TZIF_MAX_CHARS => 256;
  1         1  
  1         42  
22              
23 1     1   3 use constant HAS_QUAD => eval { my $x = pack('q>', 0); 1 };
  1         1  
  1         2  
  1         1  
  1         2277  
24              
25             sub new {
26 0 0 0 0 1   (@_ & 1 && @_ >= 3) or croak q/Usage: Time::TZif->new(path => $path)/;
27 0           my ($class, %p) = @_;
28              
29 0           my (%state, $path);
30              
31 0           while (my ($key, $v) = each %p) {
32 0 0         if ($key eq 'path') {
    0          
    0          
    0          
33 0           $path = $v;
34             }
35             elsif ($key eq 'name') {
36 0 0         valid_tzdb_timezone($v)
37             or croak qq/Invalid value for the parameter 'name'/;
38 0           $state{name} = $v;
39             }
40             elsif ($key eq 'gap_policy') {
41 0 0 0       (defined $v && exists $ValidPolicy{$v})
42             or croak qq/Invalid policy value for the parameter 'gap_policy'/;
43 0           $state{gap_policy} = $v;
44             }
45             elsif ($key eq 'overlap_policy') {
46 0 0 0       (defined $v && exists $ValidPolicy{$v})
47             or croak qq/Invalid policy value for the parameter 'overlap_policy'/;
48 0           $state{overlap_policy} = $v;
49             }
50             else {
51 0           croak qq/Unrecognised named parameter: '$key'/;
52             }
53             }
54              
55 0 0         (defined $path)
56             or croak q/Parameter 'path' is required/;
57              
58 0 0         open(my $fh, '<:raw', $path)
59             or croak qq/Unable to parse TZif: could not open '$path': '$!'/;
60              
61 0           $state{path} = $path;
62 0           $state{modified_time} = (stat $fh)[9];
63 0   0       $state{gap_policy} //= 'reject';
64 0   0       $state{overlap_policy} //= 'reject';
65              
66 0           my $self = bless \%state, $class;
67 0           $self->_parse($fh);
68 0           close($fh);
69 0           return $self;
70             }
71              
72             sub _with {
73 0     0     my ($object, %with) = @_;
74 0           return bless { %{$object}, %with }, ref $object;
  0            
75             }
76              
77             sub name {
78 0 0   0 1   @_ == 1 or croak q/Usage: $tz->name()/;
79 0           return $_[0]->{name};
80             }
81              
82             sub path {
83 0 0   0 1   @_ == 1 or croak q/Usage: $tz->path()/;
84 0           return $_[0]->{path};
85             }
86              
87             sub modified_time {
88 0 0   0 1   @_ == 1 or croak q/Usage: $tz->modified_time()/;
89 0           return $_[0]->{modified_time};
90             }
91              
92             sub gap_policy {
93 0 0   0 1   @_ == 1 or croak q/Usage: $tz->gap_policy()/;
94 0           return $_[0]->{gap_policy};
95             }
96              
97             sub overlap_policy {
98 0 0   0 1   @_ == 1 or croak q/Usage: $tz->overlap_policy()/;
99 0           return $_[0]->{overlap_policy};
100             }
101              
102             sub has_name {
103 0 0   0 1   @_ == 1 or croak q/Usage: $tz->has_name()/;
104 0           return exists $_[0]->{name};
105             }
106              
107             sub with_name {
108 0 0   0 1   @_ == 2 or croak q/Usage: $tz->with_name($name)/;
109 0           my ($self, $name) = @_;
110              
111 0 0         valid_tzdb_timezone($name)
112             or croak qq/Invalid name value/;
113              
114 0 0 0       if (!exists $self->{name} || $name ne $self->{name}) {
115 0           return _with($self, name => $name);
116             }
117 0           return $self;
118             }
119              
120             sub with_gap_policy {
121 0 0   0 1   @_ == 2 or croak q/Usage: $tz->with_gap_policy($policy)/;
122 0           my ($self, $policy) = @_;
123              
124 0 0 0       (defined $policy && exists $ValidPolicy{$policy})
125             or croak qq/Invalid policy value/;
126              
127 0 0         if ($policy ne $self->{gap_policy}) {
128 0           return _with($self, gap_policy => $policy, _posix_tz => undef);
129             }
130 0           return $self;
131             }
132              
133             sub with_overlap_policy {
134 0 0   0 1   @_ == 2 or croak q/Usage: $tz->with_overlap_policy($policy)/;
135 0           my ($self, $policy) = @_;
136              
137 0 0 0       (defined $policy && exists $ValidPolicy{$policy})
138             or croak qq/Invalid policy value/;
139              
140 0 0         if ($policy ne $self->{overlap_policy}) {
141 0           return _with($self, overlap_policy => $policy, _posix_tz => undef);
142             }
143 0           return $self;
144             }
145              
146             sub _posix_tz {
147 0     0     my ($self) = @_;
148             return $self->{_posix_tz} //= Time::TZif::POSIX->new(
149             tz_string => $self->{posix_tz},
150             gap_policy => $self->{gap_policy},
151             overlap_policy => $self->{overlap_policy},
152 0   0       );
153             }
154              
155             sub _readn {
156 0     0     my ($fh, $len) = @_;
157 0           my $got = read($fh, my $buf, $len);
158 0 0         (defined $got)
159             or croak qq/Unable to parse TZif: could not read from filehandle: '$!'/;
160 0 0         ($got == $len)
161             or croak qq/Unable to parse TZif: premature end of data (got: $got, expected: $len)/;
162 0           return $buf;
163             }
164              
165             sub _parse {
166 0     0     my ($self, $fh) = @_;
167              
168 0           my ($magic, $version, @counts) = unpack('N a x15 N6', _readn($fh, 44));
169              
170 0 0         ($magic == TZIF_MAGIC)
171             or croak q/Unable to parse TZif: not a TZif file/;
172              
173 0           my ($isutcnt, $isstdcnt, $leapcnt, $timecnt, $typecnt, $charcnt) = @counts;
174              
175 0 0 0       if (HAS_QUAD && ($version eq '2' || $version eq '3')) {
176             # Skip v1 data block
177 0           my $v1_size = $timecnt * 4
178             + $timecnt
179             + $typecnt * 6
180             + $charcnt
181             + $leapcnt * 8
182             + $isstdcnt
183             + $isutcnt;
184              
185 0 0         _readn($fh, $v1_size) if $v1_size;
186              
187             # Parse v2/v3 header
188 0           ($magic, $version, @counts) = unpack('N a x15 N6', _readn($fh, 44));
189              
190 0 0         ($magic == TZIF_MAGIC)
191             or croak q/Unable to parse TZif: invalid v2\/v3 header/;
192              
193 0           ($isutcnt, $isstdcnt, $leapcnt, $timecnt, $typecnt, $charcnt) = @counts;
194              
195 0           $self->_parse_data($fh, $timecnt, $typecnt, $charcnt,
196             $leapcnt, $isstdcnt, $isutcnt, 8);
197              
198             # Read POSIX TZ string footer
199 0           my $nl = _readn($fh, 1);
200 0 0         ($nl eq "\n")
201             or croak q/Unable to parse TZif: expected newline before POSIX TZ string/;
202              
203 0           my $posix_tz = '';
204 0           while (1) {
205 0           my $byte = eval { _readn($fh, 1) };
  0            
206 0 0         last unless defined $byte;
207 0 0         last if $byte eq "\n";
208 0           $posix_tz .= $byte;
209             }
210 0 0         $self->{posix_tz} = $posix_tz if length $posix_tz;
211             }
212             else {
213 0           $self->_parse_data($fh, $timecnt, $typecnt, $charcnt,
214             $leapcnt, $isstdcnt, $isutcnt, 4);
215             }
216              
217 0           my $times = $self->{times};
218 0 0 0       if ($self->{posix_tz} && @$times) {
219 0           $self->{max_time_utc} = $times->[-1];
220 0           $self->{max_time_local} = $times->[-1] + 86400;
221             }
222             else {
223 0           $self->{max_time_utc} = ~0;
224 0           $self->{max_time_local} = ~0;
225             }
226             }
227              
228             sub _parse_data {
229 0     0     my ($self, $fh, $timecnt, $typecnt, $charcnt,
230             $leapcnt, $isstdcnt, $isutcnt, $time_size) = @_;
231              
232 0 0         ($typecnt >= 1)
233             or croak q/Unable to parse TZif: must have at least one type/;
234 0 0         ($timecnt <= TZIF_MAX_TIMES)
235 0           or croak qq/Unable to parse TZif: too many transitions times: $timecnt (max: @{[TZIF_MAX_TIMES]})/;
236 0 0         ($typecnt <= TZIF_MAX_TYPES)
237 0           or croak qq/Unable to parse TZif: too many type records: $typecnt (max: @{[TZIF_MAX_TYPES]})/;
238 0 0         ($charcnt <= TZIF_MAX_CHARS)
239 0           or croak qq/Unable to parse TZif: too many abbreviation characters: $charcnt (max: @{[TZIF_MAX_CHARS]})/;
240              
241 0 0         my $time_fmt = ($time_size == 8) ? 'q>' : 'l>';
242              
243             # Transition times
244 0           my @times = unpack("(${time_fmt})*", _readn($fh, $timecnt * $time_size));
245              
246             # Transition type indices
247 0           my @type_indices = unpack('C*', _readn($fh, $timecnt));
248              
249 0           foreach my $idx (@type_indices) {
250 0 0         ($idx < $typecnt)
251 0           or croak qq/Unable to parse TZif: invalid type index: $idx (max: @{[$typecnt - 1]})/;
252             }
253              
254             # Type info records: 6 bytes each (offset[4] + dst[1] + abbridx[1])
255 0           my @types;
256 0           for (my $i = 0; $i < $typecnt; $i++) {
257 0           my ($offset, $dst, $abbridx) = unpack 'l> C C', _readn($fh, 6);
258              
259 0 0 0       ($offset > -86400 && $offset < 86400)
260             or croak qq/Unable to parse TZif: invalid UTC offset: $offset/;
261 0 0 0       ($dst == 0 || $dst == 1)
262             or croak qq/Unable to parse TZif: invalid DST flag: $dst/;
263 0 0         ($abbridx < $charcnt)
264             or croak qq/Unable to parse TZif: invalid abbreviation index: $abbridx/;
265              
266 0           $types[$i] = [$offset, $dst, $abbridx];
267             }
268              
269             # Abbreviation characters (NUL-terminated strings)
270 0           my $abbr_buf = _readn($fh, $charcnt);
271 0           my %abbrs;
272             {
273 0           my $pos = 0;
  0            
274 0           foreach my $str (split /\x00/, $abbr_buf, -1) {
275 0           $abbrs{$pos} = $str;
276 0           $pos += 1 + length $str;
277             }
278             }
279              
280             # Skip remaining: leap seconds, std/wall, ut/local indicators
281 0 0         my $leap_rec_size = ($time_size == 8) ? 12 : 8;
282 0           my $skip = $leapcnt * $leap_rec_size + $isstdcnt + $isutcnt;
283 0 0         _readn($fh, $skip) if $skip;
284              
285             # Resolve abbreviation indices to strings
286 0           foreach my $type (@types) {
287 0   0       $type->[2] = $abbrs{ $type->[2] } // '';
288             }
289              
290             # Find first standard (non-DST) type as the default for pre-transition times
291 0           my $first_std = $types[0];
292 0           foreach my $type (@types) {
293 0 0         if (!$type->[1]) {
294 0           $first_std = $type;
295 0           last;
296             }
297             }
298              
299             # Build resolved type array with sentinel:
300             # types[0] = default type (first standard type)
301             # types[i+1] = type that takes effect at transition times[i]
302 0           my @resolved = ($first_std);
303 0           foreach my $idx (@type_indices) {
304 0           push @resolved, $types[$idx];
305             }
306              
307 0           $self->{times} = \@times;
308 0           $self->{types} = \@resolved;
309             }
310              
311             # Internal method - not part of the public API. May change or be removed without notice.
312             sub _transition_times {
313 0 0   0     @_ == 1 or croak q/Usage: $tz->_transition_times()/;
314 0           my ($self) = @_;
315 0 0         return wantarray ? @{ $self->{times} } : [ @{ $self->{times} } ];
  0            
  0            
316             }
317              
318             sub offset_for_utc {
319 0 0   0 1   @_ == 2 or croak q/Usage: $tz->offset_for_utc($time)/;
320 0           my ($self, $time) = @_;
321 0 0         if ($time <= $self->{max_time_utc}) {
322 0           return $self->{types}[ upper_bound($self->{times}, $time) ][0];
323             }
324             else {
325 0           return $self->_posix_tz->offset_for_utc($time);
326             }
327             }
328              
329             sub type_info_for_utc {
330 0 0   0 1   @_ == 2 or croak q/Usage: $tz->type_info_for_utc($time)/;
331 0           my ($self, $time) = @_;
332 0 0         if ($time <= $self->{max_time_utc}) {
333 0           my $type = $self->{types}[ upper_bound($self->{times}, $time) ];
334 0           return @$type;
335             }
336             else {
337 0           return $self->_posix_tz->type_info_for_utc($time);
338             }
339             }
340              
341             sub offset_for_local {
342 0 0   0 1   @_ >= 2 or croak q/Usage: $tz->offset_for_local($time, %opts)/;
343 0           my ($self, $time) = @_;
344 0 0         if ($time > $self->{max_time_local}) {
345 0           return shift->_posix_tz->offset_for_local(@_);
346             }
347 0           my $type = &_resolve_local;
348 0           return $type->[0];
349             }
350              
351             sub type_info_for_local {
352 0 0   0 1   @_ >= 2 or croak q/Usage: $tz->type_info_for_local($time, %opts)/;
353 0           my ($self, $time) = @_;
354 0 0         if ($time > $self->{max_time_local}) {
355 0           return shift->_posix_tz->type_info_for_local(@_);
356             }
357 0           my $type = &_resolve_local;
358 0           return @$type;
359             }
360              
361             sub _resolve_local {
362 0 0   0     (@_ & 1) == 0 or croak q/Usage: $tz->offset_for_local($time, %opts)/;
363 0           my ($self, $time, %p) = @_;
364              
365 0           my ($gap_policy, $overlap_policy);
366              
367 0           while (my ($key, $v) = each %p) {
368 0 0         if ($key eq 'gap_policy') {
    0          
369 0 0 0       (defined $v && exists $ValidPolicy{$v})
370             or croak qq/Invalid policy value for the parameter 'gap_policy'/;
371 0           $gap_policy = $v;
372             }
373             elsif ($key eq 'overlap_policy') {
374 0 0 0       (defined $v && exists $ValidPolicy{$v})
375             or croak qq/Invalid policy value for the parameter 'overlap_policy'/;
376 0           $overlap_policy = $v;
377             }
378             else {
379 0           croak qq/Unrecognised named parameter: '$key'/;
380             }
381             }
382              
383 0           my $times = $self->{times};
384 0           my $types = $self->{types};
385              
386             # No transitions
387 0 0         return $types->[0] unless @$times;
388              
389             # Find transitions within ±24 hours of the local time.
390             # Since UTC offsets are bounded by (-86400, 86400), any transition
391             # that could affect this local time must fall within this range.
392 0           my ($lo, $hi) = range_bounds($times, $time - 86400, $time + 86400);
393              
394             # No transitions nearby
395 0 0         return $types->[$lo] if $lo >= $hi;
396              
397 0           my $result_idx = $lo;
398              
399 0           for (my $i = $lo; $i < $hi; $i++) {
400 0           my $boundary = $time - $times->[$i];
401 0           my $prev = $types->[$i];
402 0           my $next = $types->[$i + 1];
403 0           my $prev_off = $prev->[0];
404 0           my $next_off = $next->[0];
405              
406 0 0         if ($prev_off < $next_off) {
    0          
407             # Spring forward: gap in [T + prev_off, T + next_off)
408 0 0 0       if ($prev_off <= $boundary && $boundary < $next_off) {
409 0   0       $gap_policy //= $self->{gap_policy};
410 0           return _apply_policy($gap_policy, $prev, $next,
411             'Unable to resolve local time: non-existing time (gap)');
412             }
413 0 0         $result_idx = $i + 1 if $boundary >= $next_off;
414             }
415             elsif ($prev_off > $next_off) {
416             # Fall back: overlap in [T + next_off, T + prev_off)
417 0 0 0       if ($next_off <= $boundary && $boundary < $prev_off) {
418 0   0       $overlap_policy //= $self->{overlap_policy};
419 0           return _apply_policy($overlap_policy, $prev, $next,
420             'Unable to resolve local time: ambiguous time (overlap)');
421             }
422 0 0         $result_idx = $i + 1 if $boundary >= $prev_off;
423             }
424             else {
425 0 0         $result_idx = $i + 1 if $boundary >= $prev_off;
426             }
427             }
428              
429 0           return $types->[$result_idx];
430             }
431              
432             sub _apply_policy {
433 0     0     my ($policy, $prev, $next, $message) = @_;
434              
435 0 0         if ($policy eq 'earlier') { return $prev }
  0 0          
    0          
    0          
436 0           elsif ($policy eq 'later') { return $next }
437             elsif ($policy eq 'std') {
438 0 0         return $prev->[1] ? $next : $prev;
439             }
440             elsif ($policy eq 'dst') {
441 0 0         return $prev->[1] ? $prev : $next;
442             }
443             else {
444 0           croak $message;
445             }
446             }
447              
448             1;