File Coverage

blib/lib/Time/TZif.pm
Criterion Covered Total %
statement 31 181 17.1
branch 0 112 0.0
condition 0 45 0.0
subroutine 10 24 41.6
pod 8 9 88.8
total 49 371 13.2


line stmt bran cond sub pod time code
1             package Time::TZif;
2 1     1   204021 use strict;
  1         2  
  1         60  
3 1     1   5 use warnings;
  1         1  
  1         125  
4 1     1   12 use v5.10;
  1         2  
5              
6             our $VERSION = '0.87';
7              
8 1     1   5 use Carp qw[croak];
  1         1  
  1         60  
9 1     1   2346 use Time::Str::Util qw[range_bounds upper_bound];
  1         15  
  1         77  
10              
11             my %ValidPolicy = (
12             earlier => 1, later => 1, std => 1, dst => 1, reject => 1
13             );
14              
15 1     1   5 use constant TZIF_MAGIC => 0x545A6966;
  1         1  
  1         70  
16 1     1   4 use constant TZIF_MAX_TIMES => 2400;
  1         1  
  1         34  
17 1     1   3 use constant TZIF_MAX_TYPES => 256;
  1         1  
  1         36  
18 1     1   3 use constant TZIF_MAX_CHARS => 256;
  1         1  
  1         47  
19              
20 1     1   4 use constant HAS_QUAD => eval { my $x = pack('q>', 0); 1 };
  1         1  
  1         1  
  1         2  
  1         1764  
21              
22             sub new {
23 0 0 0 0 1   (@_ & 1 && @_ >= 3) or croak q/Usage: Time::TZif->new(filename => $filename)/;
24 0           my ($class, %p) = @_;
25              
26 0           my ($filename, $gap_policy, $overlap_policy);
27              
28 0           while (my ($key, $v) = each %p) {
29 0 0         if ($key eq 'filename') {
    0          
    0          
30 0           $filename = $v;
31             }
32             elsif ($key eq 'gap_policy') {
33 0 0 0       (defined $v && exists $ValidPolicy{$v})
34             or croak qq/Invalid policy value for the parameter 'gap_policy'/;
35 0           $gap_policy = $v;
36             }
37             elsif ($key eq 'overlap_policy') {
38 0 0 0       (defined $v && exists $ValidPolicy{$v})
39             or croak qq/Invalid policy value for the parameter 'overlap_policy'/;
40 0           $overlap_policy = $v;
41             }
42             else {
43 0           croak qq/Unrecognised named parameter: '$key'/;
44             }
45             }
46              
47 0 0         (defined $filename)
48             or croak q/Parameter 'filename' is required/;
49              
50 0   0       $gap_policy //= 'reject';
51 0   0       $overlap_policy //= 'reject';
52              
53 0 0         open(my $fh, '<:raw', $filename)
54             or croak qq/Unable to parse TZif: could not open '$filename': '$!'/;
55              
56 0           my $self = bless {
57             filename => $filename,
58             gap_policy => $gap_policy,
59             overlap_policy => $overlap_policy,
60             }, $class;
61              
62 0           $self->_parse($fh);
63 0           close($fh);
64 0           return $self;
65             }
66              
67 0     0 1   sub filename { $_[0]->{filename} }
68 0     0 1   sub gap_policy { $_[0]->{gap_policy} }
69 0     0 1   sub overlap_policy { $_[0]->{overlap_policy} }
70              
71             sub _readn {
72 0     0     my ($fh, $len) = @_;
73 0           my $got = read($fh, my $buf, $len);
74 0 0         (defined $got)
75             or croak qq/Unable to parse TZif: could not read from filehandle: '$!'/;
76 0 0         ($got == $len)
77             or croak qq/Unable to parse TZif: premature end of data (got: $got, expected: $len)/;
78 0           return $buf;
79             }
80              
81             sub _parse {
82 0     0     my ($self, $fh) = @_;
83              
84 0           my ($magic, $version, @counts) = unpack('N a x15 N6', _readn($fh, 44));
85              
86 0 0         ($magic == TZIF_MAGIC)
87             or croak q/Unable to parse TZif: not a TZif file/;
88              
89 0           my ($isutcnt, $isstdcnt, $leapcnt, $timecnt, $typecnt, $charcnt) = @counts;
90              
91 0 0 0       if (HAS_QUAD && ($version eq '2' || $version eq '3')) {
92             # Skip v1 data block
93 0           my $v1_size = $timecnt * 4
94             + $timecnt
95             + $typecnt * 6
96             + $charcnt
97             + $leapcnt * 8
98             + $isstdcnt
99             + $isutcnt;
100              
101 0 0         _readn($fh, $v1_size) if $v1_size;
102              
103             # Parse v2/v3 header
104 0           ($magic, $version, @counts) = unpack('N a x15 N6', _readn($fh, 44));
105              
106 0 0         ($magic == TZIF_MAGIC)
107             or croak q/Unable to parse TZif: invalid v2\/v3 header/;
108              
109 0           ($isutcnt, $isstdcnt, $leapcnt, $timecnt, $typecnt, $charcnt) = @counts;
110              
111 0           $self->_parse_data($fh, $timecnt, $typecnt, $charcnt,
112             $leapcnt, $isstdcnt, $isutcnt, 8);
113              
114             # Read POSIX TZ string footer
115 0           my $nl = _readn($fh, 1);
116 0 0         ($nl eq "\n")
117             or croak q/Unable to parse TZif: expected newline before POSIX TZ string/;
118              
119 0           my $posix_tz = '';
120 0           while (1) {
121 0           my $byte = eval { _readn($fh, 1) };
  0            
122 0 0         last unless defined $byte;
123 0 0         last if $byte eq "\n";
124 0           $posix_tz .= $byte;
125             }
126 0 0         $self->{posix_tz} = $posix_tz if length $posix_tz;
127             }
128             else {
129 0           $self->_parse_data($fh, $timecnt, $typecnt, $charcnt,
130             $leapcnt, $isstdcnt, $isutcnt, 4);
131             }
132             }
133              
134             sub _parse_data {
135 0     0     my ($self, $fh, $timecnt, $typecnt, $charcnt,
136             $leapcnt, $isstdcnt, $isutcnt, $time_size) = @_;
137              
138 0 0         ($typecnt >= 1)
139             or croak q/Unable to parse TZif: must have at least one type/;
140 0 0         ($timecnt <= TZIF_MAX_TIMES)
141 0           or croak qq/Unable to parse TZif: too many transitions times: $timecnt (max: @{[TZIF_MAX_TIMES]})/;
142 0 0         ($typecnt <= TZIF_MAX_TYPES)
143 0           or croak qq/Unable to parse TZif: too many type records: $typecnt (max: @{[TZIF_MAX_TYPES]})/;
144 0 0         ($charcnt <= TZIF_MAX_CHARS)
145 0           or croak qq/Unable to parse TZif: too many abbreviation characters: $charcnt (max: @{[TZIF_MAX_CHARS]})/;
146              
147 0 0         my $time_fmt = ($time_size == 8) ? 'q>' : 'l>';
148              
149             # Transition times
150 0           my @times = unpack("(${time_fmt})*", _readn($fh, $timecnt * $time_size));
151              
152             # Transition type indices
153 0           my @type_indices = unpack('C*', _readn($fh, $timecnt));
154              
155 0           foreach my $idx (@type_indices) {
156 0 0         ($idx < $typecnt)
157 0           or croak qq/Unable to parse TZif: invalid type index: $idx (max: @{[$typecnt - 1]})/;
158             }
159              
160             # Type info records: 6 bytes each (offset[4] + dst[1] + abbridx[1])
161 0           my @types;
162 0           for (my $i = 0; $i < $typecnt; $i++) {
163 0           my ($offset, $dst, $abbridx) = unpack 'l> C C', _readn($fh, 6);
164              
165 0 0 0       ($offset > -86400 && $offset < 86400)
166             or croak qq/Unable to parse TZif: invalid UTC offset: $offset/;
167 0 0 0       ($dst == 0 || $dst == 1)
168             or croak qq/Unable to parse TZif: invalid DST flag: $dst/;
169 0 0         ($abbridx < $charcnt)
170             or croak qq/Unable to parse TZif: invalid abbreviation index: $abbridx/;
171              
172 0           $types[$i] = [$offset, $dst, $abbridx];
173             }
174              
175             # Abbreviation characters (NUL-terminated strings)
176 0           my $abbr_buf = _readn($fh, $charcnt);
177 0           my %abbrs;
178             {
179 0           my $pos = 0;
  0            
180 0           foreach my $str (split /\x00/, $abbr_buf, -1) {
181 0           $abbrs{$pos} = $str;
182 0           $pos += 1 + length $str;
183             }
184             }
185              
186             # Skip remaining: leap seconds, std/wall, ut/local indicators
187 0 0         my $leap_rec_size = ($time_size == 8) ? 12 : 8;
188 0           my $skip = $leapcnt * $leap_rec_size + $isstdcnt + $isutcnt;
189 0 0         _readn($fh, $skip) if $skip;
190              
191             # Resolve abbreviation indices to strings
192 0           foreach my $type (@types) {
193 0   0       $type->[2] = $abbrs{ $type->[2] } // '';
194             }
195              
196             # Find first standard (non-DST) type as the default for pre-transition times
197 0           my $first_std = $types[0];
198 0           foreach my $type (@types) {
199 0 0         if (!$type->[1]) {
200 0           $first_std = $type;
201 0           last;
202             }
203             }
204              
205             # Build resolved type array with sentinel:
206             # types[0] = default type (first standard type)
207             # types[i+1] = type that takes effect at transition times[i]
208 0           my @resolved = ($first_std);
209 0           foreach my $idx (@type_indices) {
210 0           push @resolved, $types[$idx];
211             }
212              
213 0           $self->{times} = \@times;
214 0           $self->{types} = \@resolved;
215             }
216              
217             sub transitions_times {
218 0 0   0 0   @_ == 1 or croak q/Usage: $tz->transitions_times()/;
219 0           my ($self) = @_;
220 0 0         return wantarray ? @{ $self->{times} } : [ @{ $self->{times} } ];
  0            
  0            
221             }
222              
223             sub offset_for_utc {
224 0 0   0 1   @_ == 2 or croak q/Usage: $tz->offset_for_utc($time)/;
225 0           my ($self, $time) = @_;
226 0           return $self->{types}[ upper_bound($self->{times}, $time) ][0];
227             }
228              
229             sub type_info_for_utc {
230 0 0   0 1   @_ == 2 or croak q/Usage: $tz->type_info_for_utc($time)/;
231 0           my ($self, $time) = @_;
232 0           my $type = $self->{types}[ upper_bound($self->{times}, $time) ];
233 0           return @$type;
234             }
235              
236             sub offset_for_local {
237 0 0   0 1   @_ >= 2 or croak q/Usage: $tz->offset_for_local($time, %opts)/;
238 0           my $type = &_resolve_local;
239 0           return $type->[0];
240             }
241              
242             sub type_info_for_local {
243 0 0   0 1   @_ >= 2 or croak q/Usage: $tz->type_info_for_local($time, %opts)/;
244 0           my $type = &_resolve_local;
245 0           return @$type;
246             }
247              
248             sub _resolve_local {
249 0 0 0 0     ((@_ & 1) == 0 && @_ >= 2) or croak q/Usage: $tz->offset_for_local($time, %opts)/;
250 0           my ($self, $time, %p) = @_;
251              
252 0           my ($gap_policy, $overlap_policy);
253              
254 0           while (my ($key, $v) = each %p) {
255 0 0         if ($key eq 'gap_policy') {
    0          
256 0 0 0       (defined $v && exists $ValidPolicy{$v})
257             or croak qq/Invalid policy value for the parameter 'gap_policy'/;
258 0           $gap_policy = $v;
259             }
260             elsif ($key eq 'overlap_policy') {
261 0 0 0       (defined $v && exists $ValidPolicy{$v})
262             or croak qq/Invalid policy value for the parameter 'overlap_policy'/;
263 0           $overlap_policy = $v;
264             }
265             else {
266 0           croak qq/Unrecognised named parameter: '$key'/;
267             }
268             }
269              
270 0           my $times = $self->{times};
271 0           my $types = $self->{types};
272              
273             # No transitions
274 0 0         return $types->[0] unless @$times;
275              
276             # Find transitions within ±24 hours of the local time.
277             # Since UTC offsets are bounded by (-86400, 86400), any transition
278             # that could affect this local time must fall within this range.
279 0           my ($lo, $hi) = range_bounds($times, $time - 86400, $time + 86400);
280              
281             # No transitions nearby
282 0 0         return $types->[$lo] if $lo >= $hi;
283              
284 0           my $result_idx = $lo;
285              
286 0           for (my $i = $lo; $i < $hi; $i++) {
287 0           my $boundary = $time - $times->[$i];
288 0           my $prev = $types->[$i];
289 0           my $next = $types->[$i + 1];
290 0           my $prev_off = $prev->[0];
291 0           my $next_off = $next->[0];
292              
293 0 0         if ($prev_off < $next_off) {
    0          
294             # Spring forward: gap in [T + prev_off, T + next_off)
295 0 0 0       if ($prev_off <= $boundary && $boundary < $next_off) {
296 0   0       $gap_policy //= $self->{gap_policy};
297 0           return _apply_policy($gap_policy, $prev, $next,
298             'Unable to resolve local time: non-existing time (gap)');
299             }
300 0 0         $result_idx = $i + 1 if $boundary >= $next_off;
301             }
302             elsif ($prev_off > $next_off) {
303             # Fall back: overlap in [T + next_off, T + prev_off)
304 0 0 0       if ($next_off <= $boundary && $boundary < $prev_off) {
305 0   0       $overlap_policy //= $self->{overlap_policy};
306 0           return _apply_policy($overlap_policy, $prev, $next,
307             'Unable to resolve local time: ambiguous time (overlap)');
308             }
309 0 0         $result_idx = $i + 1 if $boundary >= $prev_off;
310             }
311             else {
312 0 0         $result_idx = $i + 1 if $boundary >= $prev_off;
313             }
314             }
315              
316 0           return $types->[$result_idx];
317             }
318              
319             sub _apply_policy {
320 0     0     my ($policy, $prev, $next, $message) = @_;
321              
322 0 0         if ($policy eq 'earlier') { return $prev }
  0 0          
    0          
    0          
323 0           elsif ($policy eq 'later') { return $next }
324             elsif ($policy eq 'std') {
325 0 0         return $prev->[1] ? $next : $prev;
326             }
327             elsif ($policy eq 'dst') {
328 0 0         return $prev->[1] ? $prev : $next;
329             }
330             else {
331 0           croak $message;
332             }
333             }
334              
335             1;