File Coverage

blib/lib/Time/LeapSecond.pm
Criterion Covered Total %
statement 144 149 96.6
branch 62 72 86.1
condition 13 21 61.9
subroutine 24 25 96.0
pod 8 8 100.0
total 251 275 91.2


line stmt bran cond sub pod time code
1             package Time::LeapSecond;
2 1     1   687 use strict;
  1         1  
  1         27  
3 1     1   3 use warnings;
  1         1  
  1         32  
4 1     1   7 use v5.10.1;
  1         3  
5              
6 1     1   3 use Carp qw[croak];
  1         1  
  1         42  
7 1     1   3 use Exporter qw[import];
  1         1  
  1         17  
8 1     1   3 use List::Util qw[min];
  1         1  
  1         42  
9 1     1   3 use Time::Str::Calendar qw[ymd_to_rdn];
  1         2  
  1         23  
10 1     1   366 use Time::Str::Token qw[parse_month];
  1         2  
  1         50  
11 1         121 use Time::Str::Util qw[upper_bound
12 1     1   356 find_tzdb_directory];
  1         2  
13              
14             BEGIN {
15 1     1   3 our $VERSION = '0.91';
16 1         3 our @EXPORT_OK = qw[ posix_tai_offset
17             posix_to_tai
18             tai_to_posix
19             rdn_leap_correction
20             load_leapseconds_tzdb
21             load_leapseconds_iers
22             parse_leapseconds_tzdb
23             parse_leapseconds_iers ];
24 1         32 our %EXPORT_TAGS = ( all => \@EXPORT_OK );
25             }
26              
27             # TAI-UTC offset in seconds in effect before the first leap second
28 1     1   5 use constant TAI_UTC_BASE => 10;
  1         1  
  1         59  
29              
30 1     1   4 use constant RDN_UNIX_EPOCH => 719163; # 1970-01-01
  1         1  
  1         29  
31 1     1   3 use constant SECS_PER_DAY => 86400;
  1         1  
  1         25  
32 1     1   3 use constant NTP_UNIX_DELTA => 2208988800; # 1900-01-01T00:00:00Z
  1         1  
  1         1709  
33              
34             # Tables describing the leap second history, indexed in parallel and in
35             # ascending order, all populated by _load_tables() (from the system leap
36             # seconds file when available, otherwise from the built-in fallback below):
37             #
38             # @DAYS - Rata Die day number of the day that carries each leap
39             # second, used by rdn_leap_correction().
40             # @TIMES - POSIX epoch at which each leap second takes effect: the
41             # midnight immediately following the inserted (or, for a
42             # negative leap second, the removed) 23:59:xx second. Kept as
43             # plain epochs so it can be reused from XS.
44             # @TAI_TIMES - TAI epoch at which each offset step takes effect, used by
45             # tai_to_posix(). It is $TIMES[$i] plus the smaller of the
46             # two surrounding offsets: for a positive leap second that is
47             # the old offset, so the inserted 23:59:60 folds onto the
48             # preceding 23:59:59 (matching tz/TZif); for a negative one
49             # it is the new offset, leaving a gap at the removed second.
50             # @OFFSETS - cumulative TAI-UTC offset in seconds, with one more entry
51             # than @TIMES: $OFFSETS[0] is the base offset before the
52             # first leap second and $OFFSETS[$k] the offset after $k leap
53             # seconds. Indexing it by the number of leaps at or before a
54             # time (the result of upper_bound) avoids any special case
55             # for "before the first leap second". Successive entries
56             # differ by +1 (positive leap second) or -1 (negative one).
57             # @CORRECTIONS - the +1/-1 change carried by each leap second, aligned with
58             # @OFFSETS: $CORRECTIONS[0] is 0 (the no-correction base) and
59             # $CORRECTIONS[$k] the change applied by the $k-th leap
60             # second, so rdn_leap_correction() can index it directly.
61             #
62              
63             our (
64             @TIMES, @OFFSETS, @CORRECTIONS, $EXPIRES, # Part of the public API
65             @TAI_TIMES, @DAYS
66             );
67              
68             # Build the leap second tables from a parsed (days, corrections) pair: @$days
69             # holds the Rata Die day number of each leap day in ascending order and
70             # @$corrections the +1/-1 change carried by each. Accumulates the running
71             # TAI-UTC offset (so @OFFSETS gets one more entry than @DAYS/@TIMES, with the
72             # base offset at index 0), derives the UTC transition epoch of each leap
73             # second and the TAI instant at which its offset step takes effect (the
74             # smaller of the surrounding offsets, so a positive leap second folds onto the
75             # preceding 23:59:59 as tz/TZif does), and installs all five tables. Returns
76             # the number of leap seconds installed.
77             sub _load_tables {
78 2     2   5 my ($days, $corrections, $expires) = @_;
79 2         3 my (@times, @offsets, @tai_times);
80 2         2 my $offset = TAI_UTC_BASE;
81 2         2 push @offsets, $offset;
82 2         6 for my $i (0 .. $#$days) {
83 30         25 my $prev = $offset;
84 30         27 $offset += $corrections->[$i];
85 30         31 my $time = ($days->[$i] + 1 - RDN_UNIX_EPOCH) * SECS_PER_DAY;
86 30         25 push @times, $time;
87 30         28 push @offsets, $offset;
88 30         40 push @tai_times, $time + min($prev, $offset);
89             }
90 2         17 @DAYS = @$days;
91 2         4 @TIMES = @times;
92 2         4 @OFFSETS = @offsets;
93 2         4 @TAI_TIMES = @tai_times;
94 2         4 @CORRECTIONS = (0, @$corrections);
95 2         4 $EXPIRES = $expires;
96 2         7 return scalar @TIMES;
97             }
98              
99             sub posix_tai_offset {
100 14 100   14 1 2816 @_ == 1 or croak q/Usage: posix_tai_offset(posix)/;
101 13         18 my ($posix) = @_;
102 13         53 return $OFFSETS[ upper_bound(\@TIMES, $posix) ];
103             }
104              
105             sub posix_to_tai {
106 8 100   8 1 682 @_ == 1 or croak q/Usage: posix_to_tai(posix)/;
107 7         9 my ($posix) = @_;
108 7         9 return $posix + posix_tai_offset($posix);
109             }
110              
111             sub tai_to_posix {
112 11 100   11 1 380 @_ == 1 or croak q/Usage: tai_to_posix(tai)/;
113 10         14 my ($tai) = @_;
114 10         34 return $tai - $OFFSETS[ upper_bound(\@TAI_TIMES, $tai) ];
115             }
116              
117             sub rdn_leap_correction {
118 6 100   6 1 366 @_ == 1 or croak q/Usage: rdn_leap_correction(rdn)/;
119 5         7 my ($rdn) = @_;
120 5         13 my $i = upper_bound(\@DAYS, $rdn);
121 5 100 100     34 return $i > 0 && $DAYS[$i - 1] == $rdn ? $CORRECTIONS[$i] : 0;
122             }
123              
124             {
125             my $LeapLine_Rx = qr{
126             (?(DEFINE)
127             (? (?i: Jan|Feb|Mar|Apr|May|Jun|
128             Jul|Aug|Sep|Oct|Nov|Dec ))
129             (?
130             (? [+-])
131             (? [RS])
132             )
133             \A
134             Leap \s+
135             (? [0-9]{4}) \s+
136             (? (?&MonthName)) \s+
137             (? [0-9]{1,2}) \s+
138             (?
139             (? (?&Sign)) \s+
140             (? (?&RollStat))
141             \s*
142             \z
143             }x;
144              
145             sub parse_leapseconds_tzdb {
146 12 100   12 1 8247 @_ == 1 or croak q/Usage: parse_leapseconds_tzdb(path)/;
147 11         20 my ($path) = @_;
148              
149 11 100       538 open(my $fh, '<', $path)
150             or croak qq/Unable to parse leap seconds: could not open '$path': '$!'/;
151              
152 9         20 my (@days, @corrections);
153 9         0 my $expires;
154 9         94 while (my $line = <$fh>) {
155 21 100       60 if ($line =~ /\A \s* [#] \s* expires \s+ ([0-9]{10,}) \b/ix) {
156 3         8 $expires = 0 + $1;
157 3         23 next;
158             }
159 18 100       56 next if $line !~ /\A Leap \b/x; # ignore other directives
160              
161 13 100       358 ($line =~ $LeapLine_Rx)
162             or croak qq/Unable to parse leap seconds: malformed line: '$line'/;
163              
164 11 100       71 my $corr = $+{corr} eq '+' ? 1 : -1;
165              
166             # A positive leap second inserts 23:59:60; a negative one removes
167             # 23:59:59. Anything else is not a leap second transition.
168 11 100       42 my $expected = $corr > 0 ? '23:59:60' : '23:59:59';
169 11 100       227 ($+{time} eq $expected)
170             or croak qq/Unable to parse leap seconds: unexpected leap second time '$+{time}'/;
171              
172 9         107 my $rdn = ymd_to_rdn($+{year}, parse_month($+{month}), $+{day});
173 9 100 100     147 croak qq/Unable to parse leap seconds: entries out of order at $+{year}-$+{month}-$+{day}/
174             if @days && $rdn <= $days[-1];
175              
176 8         12 push @days, $rdn;
177 8         20 push @corrections, $corr;
178             }
179 4         27 close($fh);
180              
181 4 100       96 (defined $expires)
182             or croak q/Unable to parse leap seconds: no expiration found/;
183              
184 3         18 return (\@days, \@corrections, $expires);
185             }
186             }
187              
188             {
189             my $IersLine_Rx = qr{
190             (?(DEFINE)
191             (? [0-9]+)
192             (? [0-9]+)
193             )
194             \A \s*
195             (? (?&Stamp)) \s+
196             (? (?&Offset))
197             \s* (?: [#] .* )?
198             \z
199             }x;
200              
201             sub parse_leapseconds_iers {
202 10 100   10 1 7609 @_ == 1 or croak q/Usage: parse_leapseconds_iers(path)/;
203 9         16 my ($path) = @_;
204              
205 9 100       372 open(my $fh, '<', $path)
206             or croak qq/Unable to parse leap seconds: could not open '$path': '$!'/;
207              
208             # The IERS file lists the absolute TAI-UTC offset at each NTP epoch,
209             # starting with the base row at 1972-01-01. Read in file order (which must
210             # be ascending), anchor on that base row, and turn each subsequent change
211             # into a +1/-1 correction on the day that carries the leap second. The base
212             # row must state TAI_UTC_BASE so that reconstructing absolute offsets from
213             # corrections is exact; a truncated file would not.
214 7         8 my (@days, @corrections);
215 7         10 my $base = TAI_UTC_BASE;
216 7         13 my $prev = $base;
217 7         6 my $prev_ntp;
218 7         6 my $anchored = 0;
219 7         7 my $expires;
220 7         67 while (my $line = <$fh>) {
221 17         21 chomp $line;
222 17 100       33 if ($line =~ /\A \s* [#][@] \s+ ([0-9]+)/x) {
223 1         4 $expires = $1 - NTP_UNIX_DELTA;
224 1         2 next;
225             }
226 16 100       42 next if $line =~ /\A \s* (?: [#] | \z )/x;
227              
228 15 100       246 ($line =~ $IersLine_Rx)
229             or croak qq/Unable to parse leap seconds: malformed line: '$line'/;
230              
231 14         98 my ($ntp, $off) = ($+{ntp}, $+{off});
232 14 100 100     158 croak qq/Unable to parse leap seconds: entries out of order at NTP $ntp/
233             if defined $prev_ntp && $ntp <= $prev_ntp;
234 13         16 $prev_ntp = $ntp;
235              
236 13 100       134 ($ntp % SECS_PER_DAY == 0)
237             or croak qq/Unable to parse leap seconds: NTP $ntp is not a UTC midnight/;
238              
239 12 100       17 unless ($anchored) {
240 6 100       121 ($off == $base)
241             or croak qq/Unable to parse leap seconds: table does not start at the base offset $base (got $off)/;
242 5         5 $anchored = 1;
243 5         5 $prev = $off;
244 5         15 next;
245             }
246              
247 6         8 my $delta = $off - $prev;
248 6 100       111 (abs($delta) == 1)
249             or croak qq/Unable to parse leap seconds: unexpected offset step $delta at NTP $ntp/;
250              
251             # The leap second falls on the day before the transition midnight.
252 5         9 my $rdn = int(($ntp - NTP_UNIX_DELTA) / SECS_PER_DAY) + RDN_UNIX_EPOCH - 1;
253 5         6 push @days, $rdn;
254 5         6 push @corrections, $delta;
255 5         20 $prev = $off;
256             }
257 2         14 close($fh);
258              
259 2 100       88 (defined $expires)
260             or croak q/Unable to parse leap seconds: no expiration found/;
261              
262 1         6 return (\@days, \@corrections, $expires);
263             }
264             }
265              
266             sub _tzdb_path {
267 2     2   5 my $dir = find_tzdb_directory();
268 2 50       8 return defined $dir ? "$dir/leapseconds" : undef;
269             }
270              
271             sub _iers_path {
272 0     0   0 my $dir = find_tzdb_directory();
273 0 0       0 return defined $dir ? "$dir/leap-seconds.list" : undef;
274             }
275              
276             sub load_leapseconds_tzdb {
277 6 100   6 1 152285 @_ <= 1 or croak q/Usage: load_leapseconds_tzdb([path])/;
278 5         9 my ($path) = @_;
279              
280             # In auto mode (no path) a missing system file is not an error: keep the
281             # built-in fallback. An explicit path, or a present-but-unreadable or
282             # malformed file, propagates as an exception.
283 5         9 my $explicit = defined $path;
284 5   66     16 $path //= _tzdb_path();
285 5 100       10 unless ($explicit) {
286 2 50 33     14 return undef unless defined $path && -f $path;
287             }
288              
289 3         8 my ($days, $corrections, $expires) = parse_leapseconds_tzdb($path);
290 1 50       3 @$days
291             or croak qq/Unable to parse leap seconds: no entries found in '$path'/;
292              
293 1         3 return _load_tables($days, $corrections, $expires);
294             }
295              
296             sub load_leapseconds_iers {
297 2 100   2 1 730 @_ <= 1 or croak q/Usage: load_leapseconds_iers([path])/;
298 1         2 my ($path) = @_;
299              
300 1         2 my $explicit = defined $path;
301 1   33     4 $path //= _iers_path();
302 1 50       23 unless ($explicit) {
303 0 0 0     0 return undef unless defined $path && -f $path;
304             }
305              
306 1         4 my ($days, $corrections, $expires) = parse_leapseconds_iers($path);
307 0 0         @$days
308             or croak qq/Unable to parse leap seconds: no entries found in '$path'/;
309              
310 0           return _load_tables($days, $corrections, $expires);
311             }
312              
313             # Populate the tables at load time. Try the system TZDB leap seconds file
314             # first; if it is missing, unreadable, or malformed, fall back to the
315             # built-in table below so that "use Time::LeapSecond" never dies. The
316             # fallback is the Rata Die day number of every leap second to date; every
317             # one has been positive (+1), and it is installed through the same
318             # _load_tables() path as a parsed file.
319             {
320             my @fallback = (
321             720074, # 1972-06-30
322             720258, # 1972-12-31
323             720623, # 1973-12-31
324             720988, # 1974-12-31
325             721353, # 1975-12-31
326             721719, # 1976-12-31
327             722084, # 1977-12-31
328             722449, # 1978-12-31
329             722814, # 1979-12-31
330             723361, # 1981-06-30
331             723726, # 1982-06-30
332             724091, # 1983-06-30
333             724822, # 1985-06-30
334             725736, # 1987-12-31
335             726467, # 1989-12-31
336             726832, # 1990-12-31
337             727379, # 1992-06-30
338             727744, # 1993-06-30
339             728109, # 1994-06-30
340             728658, # 1995-12-31
341             729205, # 1997-06-30
342             729754, # 1998-12-31
343             732311, # 2005-12-31
344             733407, # 2008-12-31
345             734684, # 2012-06-30
346             735779, # 2015-06-30
347             736329, # 2016-12-31
348             );
349             local $@;
350             unless (eval { load_leapseconds_tzdb() }) {
351             _load_tables(\@fallback, [ (1) x @fallback ], 1798416000);
352             }
353             }
354              
355             1;