File Coverage

blib/lib/Data/KSUID.pm
Criterion Covered Total %
statement 76 76 100.0
branch 22 22 100.0
condition 11 15 73.3
subroutine 30 30 100.0
pod 18 18 100.0
total 157 161 97.5


line stmt bran cond sub pod time code
1             # ABSTRACT: K-Sortable Unique IDentifiers
2             package Data::KSUID;
3              
4 4     4   865086 use strict;
  4         9  
  4         130  
5 4     4   37 use warnings;
  4         7  
  4         259  
6              
7             our $VERSION = '0.001';
8              
9 4     4   18 use Exporter 'import';
  4         6  
  4         294  
10              
11             our @EXPORT_OK = qw(
12             create_ksuid
13             create_ksuid_string
14             is_ksuid
15             is_ksuid_string
16             ksuid_to_string
17             next_ksuid
18             payload_of_ksuid
19             previous_ksuid
20             string_to_ksuid
21             time_of_ksuid
22             );
23              
24             our %EXPORT_TAGS = ( all => \@EXPORT_OK );
25              
26 4     4   18 use Carp ();
  4         11  
  4         64  
27 4     4   1846 use Crypt::URandom ();
  4         22858  
  4         88  
28 4     4   65 use Scalar::Util ();
  4         12  
  4         55  
29 4     4   1293 use Sub::Util ();
  4         727  
  4         107  
30              
31             # KSUID's epoch starts more recently so that the 32-bit
32             # number space gives a significantly higher useful lifetime
33             # of around 136 years from March 2017. This number (14e8)
34             # was picked to be easy to remember.
35 4     4   24 use constant EPOCH => 1_400_000_000;
  4         7  
  4         307  
36              
37             use constant {
38             # For base-62 encoding
39 4         364 KSUID_BASE => 4_294_967_296, # 0x100_000_000
40             STRING_BASE => 62,
41              
42             MAX_TIME => EPOCH + unpack( 'N', "\xff" x 4 ),
43 4     4   61 };
  4         7  
44              
45             # Public constants
46             # The ones defined above are for internal use only
47             use constant {
48 4         6365 MAX => "\xff" x 20,
49             MIN => "\x00" x 20,
50              
51             # Math::BigInt->from_bytes("\xff" x 20)->to_base(62)
52             MAX_STRING => 'aWgEPTl1tmebfsQzFP4bxwgy80V',
53             MIN_STRING => '000000000000000000000000000',
54 4     4   16 };
  4         5  
55              
56             # Trusting private functions
57              
58             my $safely_printed = Sub::Util::set_subname( safely_printed => sub {
59             require B;
60             defined $_[0]
61             ? B::perlstring($_[0])
62             : 'an undefined value';
63             });
64              
65             my %value62 = map {
66             $_ =~ /[A-Z]/ ? ( $_ => ord($_) - ord('A') + 10 ) :
67             $_ =~ /[a-z]/ ? ( $_ => ord($_) - ord('a') + 36 ) :
68             ( $_ => $_ );
69             } 0 .. 9, 'A' .. 'Z', 'a' .. 'z';
70              
71             my %digit62 = reverse %value62;
72              
73             my $ksuid_to_string = Sub::Util::set_subname( ksuid_to_string => sub {
74             my @parts = unpack 'N*', $_[0];
75             my @digits = (0) x 27;
76              
77             for ( 0 .. $#digits ) {
78             my @quotient;
79             my $remainder = 0;
80              
81             for (@parts) {
82             my $value = int($_) + int($remainder) * KSUID_BASE;
83             my $digit = $value / STRING_BASE;
84             $remainder = $value % STRING_BASE;
85              
86             push @quotient, $digit
87             if @quotient || $digit;
88             }
89              
90             # We push into this in reverse order for convenience
91             $digits[$_] = $remainder;
92              
93             @parts = @quotient or last;
94             }
95              
96             join '', @digit62{ reverse @digits };
97             });
98              
99             my $string_to_ksuid = Sub::Util::set_subname( string_to_ksuid => sub {
100             my @digits = (0) x 20;
101             my @parts = @value62{ split //, $_[0] };
102              
103             die unless @parts == 27;
104              
105             my $n = 0;
106             while ( @parts ) {
107             my @quotient;
108             my $remainder = 0;
109              
110             for (@parts) {
111             my $value = int($_) + int($remainder) * STRING_BASE;
112             my $digit = $value / KSUID_BASE;
113             $remainder = $value % KSUID_BASE;
114              
115             push @quotient, $digit % 256
116             if @quotient || $digit;
117             }
118              
119             $digits[$n++] = ( $remainder ) % 256;
120             $digits[$n++] = ( $remainder >> 8 ) % 256;
121             $digits[$n++] = ( $remainder >> 16 ) % 256;
122             $digits[$n++] = ( $remainder >> 24 ) % 256;
123              
124             @parts = @quotient or last;
125             last if $n == @digits;
126             }
127              
128             pack 'C*', reverse @digits;
129             });
130              
131             my $time_of_ksuid = Sub::Util::set_subname( time_of_ksuid => sub {
132             EPOCH + unpack 'N', substr( $_[0], 0, 4 );
133             });
134              
135             my $payload_of_ksuid = Sub::Util::set_subname( payload_of_ksuid => sub {
136             substr $_[0], 4, 20;
137             });
138              
139             my $next_ksuid = Sub::Util::set_subname( next_ksuid => sub {
140             my $k = shift;
141              
142             my $time = $k->$time_of_ksuid;
143             my $data = $k->$payload_of_ksuid;
144              
145             # Overflow
146             return create_ksuid( $time + 1, "\x00" x 16 )
147             if $data eq ( "\xff" x 16 );
148              
149             my @parts = reverse $data =~ /[\w\W]{4}/g;
150             for (@parts) {
151             $_ = pack 'N', unpack('N', $_) + 1;
152             last unless $_ eq "\x00" x 4;
153             }
154              
155             create_ksuid( $time, join '', reverse @parts );
156             });
157              
158             my $previous_ksuid = Sub::Util::set_subname( previous_ksuid => sub {
159             my $k = shift;
160              
161             my $time = $k->$time_of_ksuid;
162             my $data = $k->$payload_of_ksuid;
163              
164             # Overflow
165             return create_ksuid( $time - 1, "\xff" x 16 )
166             if $data eq ( "\x00" x 16 );
167              
168             my @parts = reverse $data =~ /[\w\W]{4}/g;
169             for (@parts) {
170             $_ = pack 'N', unpack('N', $_) - 1;
171             last unless $_ eq "\xff" x 4;
172             }
173              
174             create_ksuid( $time, join '', reverse @parts );
175             });
176              
177             # Distrustful user-facing functions
178              
179             sub create_ksuid {
180 34     34 1 289177 my ( $time, $payload ) = @_;
181              
182 34 100       98 if ( $time ) {
183 18 100       603 Carp::croak 'Timestamp must be numeric'
184             unless Scalar::Util::looks_like_number($time);
185              
186 15 100 100     753 Carp::croak "Timestamp must be between 0 and "
187             . MAX_TIME . ", got $time instead"
188             if $time < 0 || $time > MAX_TIME;
189             }
190              
191 25 100       55 if ( $payload ) {
192 15         25 my $length = length $payload;
193 15 100       865 Carp::croak "KSUID payloads must have 16 bytes, got instead $length"
194             if $length != 16;
195             }
196              
197 19   66     73 $time ||= time;
198 19   66     78 $payload ||= Crypt::URandom::urandom(16);
199              
200 19         422 pack( 'N', $time - EPOCH ) . $payload;
201             }
202              
203             sub create_ksuid_string {
204 8     8 1 12713 create_ksuid(@_)->$ksuid_to_string
205             }
206              
207             sub ksuid_to_string {
208 10 100   10 1 195601 Carp::croak 'Expected a valid KSUID, got instead '
209             . $_[0]->$safely_printed
210             unless is_ksuid($_[0]);
211              
212 4         15 goto $ksuid_to_string;
213             }
214              
215             sub string_to_ksuid {
216 10 100   10 1 9374 Carp::croak 'Expected a string KSUID, got instead '
217             . $_[0]->$safely_printed
218             unless is_ksuid_string($_[0]);
219              
220 4         16 goto $string_to_ksuid;
221             }
222              
223             sub time_of_ksuid {
224 8 100   8 1 11984 Carp::croak 'Expected a valid KSUID, got instead '
225             . $_[0]->$safely_printed
226             unless is_ksuid($_[0]);
227              
228 2         8 goto $time_of_ksuid;
229             }
230              
231             sub payload_of_ksuid {
232 12 100   12 1 8532 Carp::croak 'Expected a valid KSUID, got instead '
233             . $_[0]->$safely_printed
234             unless is_ksuid($_[0]);
235              
236 6         20 goto $payload_of_ksuid;
237             }
238              
239             sub next_ksuid {
240 7 100   7 1 10605 Carp::croak 'Expected a valid KSUID, got instead '
241             . $_[0]->$safely_printed
242             unless is_ksuid($_[0]);
243              
244 1         6 goto $next_ksuid;
245             }
246              
247             sub previous_ksuid {
248 7 100   7 1 9856 Carp::croak 'Expected a valid KSUID, got instead '
249             . $_[0]->$safely_printed
250             unless is_ksuid($_[0]);
251              
252 1         3 goto $previous_ksuid;
253             }
254              
255             sub is_ksuid {
256 50   66 50 1 9763 return defined $_[0]
257             && length $_[0] == 20
258             && $_[0] ge MIN
259             && $_[0] le MAX;
260             }
261              
262             sub is_ksuid_string {
263 16   66 16 1 5984 return defined $_[0]
264             && length $_[0] == 27
265             && $_[0] ge MIN_STRING
266             && $_[0] le MAX_STRING
267             && $_[0] !~ /[^0-9A-Za-z]/;
268             }
269              
270             ## OO interface
271              
272             use overload
273             '""' => \&string,
274             'cmp' => Sub::Util::set_subname( cmp => sub {
275 3     3   925 $_[0]->bytes cmp $_[1]->bytes
276 4         77 }),
277 4     4   31 ;
  4         5  
278              
279             sub new {
280 11     11 1 395005 my $class = shift;
281 11         23 my $self = create_ksuid(@_);
282 6         25 bless \$self, $class;
283             }
284              
285             sub parse {
286 1     1 1 638 my $class = shift;
287 1         5 my $self = string_to_ksuid(@_);
288 1         8 bless \$self, $class;
289             }
290              
291 31     31 1 42 sub bytes { ${ $_[0] } }
  31         158  
292 1     1 1 3 sub payload { $_[0]->bytes->$payload_of_ksuid }
293 17     17 1 2292 sub string { $_[0]->bytes->$ksuid_to_string }
294 1     1 1 2 sub time { $_[0]->bytes->$time_of_ksuid }
295              
296             sub next {
297 1     1 1 5 my $self = $_[0];
298 1         4 my $next = $self->bytes->$next_ksuid;
299 1         8 bless \$next, ref $self;
300             }
301              
302             sub previous {
303 1     1 1 2 my $self = $_[0];
304 1         3 my $prev = $self->bytes->$previous_ksuid;
305 1         9 bless \$prev, ref $self;
306             }
307              
308             # Clean our namespace
309             delete @Data::KSUID::{qw(
310             MAX_TIME
311             EPOCH
312             KSUID_BASE
313             STRING_BASE
314             )};
315              
316             1;