File Coverage

lib/ULID/Tiny.pm
Criterion Covered Total %
statement 98 99 98.9
branch 21 22 95.4
condition 11 13 84.6
subroutine 15 15 100.0
pod 2 2 100.0
total 147 151 97.3


line stmt bran cond sub pod time code
1             package ULID::Tiny;
2              
3 1     1   78563 use strict;
  1         1  
  1         25  
4 1     1   3 use warnings;
  1         1  
  1         35  
5 1     1   9 use v5.16;
  1         2  
6              
7 1     1   373 use Crypt::SysRandom qw(random_bytes);
  1         2694  
  1         54  
8 1     1   5 use Time::HiRes qw(time);
  1         0  
  1         6  
9 1     1   43 use Fcntl qw(O_RDONLY);
  1         1  
  1         28  
10              
11 1     1   4 use Exporter 'import';
  1         1  
  1         801  
12              
13             our $VERSION = '1.0.0';
14              
15             our @EXPORT = qw(ulid ulid_date);
16             our @EXPORT_OK = qw(ulid ulid_date);
17              
18             my @CROCKFORD_CHARS = split //, '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
19              
20             ###############################################################################
21             # Public API
22             ###############################################################################
23              
24             sub ulid {
25 1026     1026 1 141837 my (%opts) = @_;
26              
27 1026         674 my $ts;
28 1026 100       936 if (defined($opts{time})) {
29 23         45 $ts = _encode_timestamp($opts{time});
30             } else {
31 1003         817 $ts = _unixtime_ms_48bit();
32             }
33              
34 1026         2566 my $rand = random_bytes(10);
35 1026         804 my $ret = '';
36              
37 1026         693 state $prev_ts = 0;
38 1026         663 state $prev_ulid = "";
39              
40 1026 100 100     2536 if (!$opts{unique} && $prev_ts && ($ts eq $prev_ts)) {
      100        
41 965         890 $ret = _crockford_increment($prev_ulid);
42             } else {
43             # 48 bits of timestamp + 80 bits of randomness
44 61         58 my $raw = $ts . $rand;
45              
46 61         74 $ret = _crockford_encode($raw);
47             }
48              
49 1026 100       1156 if (!$opts{unique}) {
50 1025         736 $prev_ts = $ts;
51 1025         781 $prev_ulid = $ret;
52             }
53              
54 1026 100       975 if ($opts{binary}) {
55 2         4 my $bits = _crockford_decode_bits($ret);
56 2         4 $bits = substr($bits, 0, 128);
57 2         6 return pack("B*", $bits);
58             }
59              
60 1024         1955 return $ret;
61             }
62              
63             # Extract the millisecond epoch timestamp from a ULID string
64             sub ulid_date {
65 7     7 1 1056 my ($ulid_str) = @_;
66              
67 7 100 100     22 if (!defined $ulid_str || length($ulid_str) != 26) {
68 2         29 die "Invalid ULID: must be exactly 26 characters";
69             }
70              
71             # The first 10 characters of a ULID encode the 48-bit timestamp.
72             # 10 Crockford chars = 50 bits, but only the top 48 are the timestamp
73             # (the encoder right-pads 2 zero bits to reach a multiple of 5).
74 5         8 my $time_part = substr($ulid_str, 0, 10);
75 5         7 my $raw = _crockford_decode_int($time_part);
76 5         5 my $ms = $raw >> 2; # discard the 2 padding bits
77              
78 5         8 return $ms;
79             }
80              
81             ###############################################################################
82             # Internal functions
83             ###############################################################################
84              
85             sub _crockford_increment {
86 965     965   825 my ($str) = @_;
87              
88 965         564 state %val;
89              
90             # Build the reverse mapping table (once)
91 965 100       895 if (!scalar(%val)) {
92 1         17 @val{@CROCKFORD_CHARS} = (0..$#CROCKFORD_CHARS);
93             }
94              
95 965         2811 my @out = reverse split //, uc($str);
96 965         857 my $carry = 1;
97              
98 965         1083 for my $i (0 .. $#out) {
99 1956 100       1843 last unless $carry;
100              
101 991         807 my $v = $val{$out[$i]};
102 991         633 $v += $carry;
103              
104 991 100       901 if ($v >= 32) {
105 26         21 $out[$i] = $CROCKFORD_CHARS[0];
106 26         23 $carry = 1;
107             } else {
108 965         758 $out[$i] = $CROCKFORD_CHARS[$v];
109 965         755 $carry = 0;
110             }
111             }
112              
113 965 50       921 if ($carry) {
114 0         0 push(@out, '1');
115             }
116              
117 965         2637 return join('', reverse @out);
118             }
119              
120             sub _crockford_encode {
121 61     61   63 my ($bytes) = @_;
122 61         128 my $bits = unpack("B*", $bytes);
123 61         51 my $result = '';
124              
125             # Pad bits to multiple of 5
126 61         71 my $pad = (5 - (length($bits) % 5)) % 5;
127 61         103 $bits .= '0' x $pad;
128              
129 61         98 for (my $i = 0; $i < length($bits); $i += 5) {
130 1586         1298 my $chunk = substr($bits, $i, 5);
131 1586         1058 my $index = 0;
132 1586         1832 for my $bit (split //, $chunk) {
133 7930         6759 $index = ($index << 1) | $bit;
134             }
135              
136 1586         2253 $result .= $CROCKFORD_CHARS[$index];
137             }
138              
139 61         102 return $result;
140             }
141              
142             # Decode a Crockford Base32 string to a decimal integer (for timestamps)
143             sub _crockford_decode_int {
144 5     5   5 my ($str) = @_;
145              
146 5         4 state %val;
147 5 100       8 if (!scalar(%val)) {
148 1         16 @val{@CROCKFORD_CHARS} = (0..$#CROCKFORD_CHARS);
149             }
150              
151 5         12 my $n = 0;
152 5         12 for my $ch (split //, uc($str)) {
153 50   50     54 $n = $n * 32 + ($val{$ch} // die "Invalid Crockford character: $ch");
154             }
155              
156 5         11 return $n;
157             }
158              
159             # Decode a Crockford Base32 string to a binary bit string
160             sub _crockford_decode_bits {
161 2     2   3 my ($str) = @_;
162              
163 2         2 state %val;
164 2 100       3 if (!scalar(%val)) {
165 1         12 @val{@CROCKFORD_CHARS} = (0..$#CROCKFORD_CHARS);
166             }
167              
168 2         3 my $bits = '';
169 2         7 for my $ch (split //, uc($str)) {
170 52   50     52 my $v = $val{$ch} // die "Invalid Crockford character: $ch";
171 52         48 $bits .= sprintf("%05b", $v);
172             }
173              
174 2         5 return $bits;
175             }
176              
177             sub _unixtime_ms_48bit {
178 1003     1003   1085 my $ms = int(time() * 1000);
179              
180 1003         1506 return pack("H*", sprintf("%012X", $ms));
181             }
182              
183             sub _encode_timestamp {
184 23     23   37 my ($epoch_ms) = @_;
185              
186 23         118 return pack("H*", sprintf("%012X", int($epoch_ms)));
187             }
188              
189             1;
190              
191             __END__