File Coverage

lib/At/Protocol/URI.pm
Criterion Covered Total %
statement 257 264 97.3
branch 51 54 94.4
condition 49 60 81.6
subroutine 39 40 97.5
pod 12 15 80.0
total 408 433 94.2


line stmt bran cond sub pod time code
1             package At::Protocol::URI 1.0 { # https://github.com/bluesky-social/atproto/blob/main/packages/syntax/src/aturi.ts
2 4     4   245657 use v5.42;
  4         15  
3 4     4   22 no warnings qw[experimental::builtin experimental::try];
  4         7  
  4         228  
4 4     4   508 use At::Error qw[register throw];
  4         9  
  4         20  
5 4     4   2102 use At::Protocol::DID qw[ensureValidDid ensureValidDidRegex];
  4         11  
  4         402  
6 4     4   2251 use At::Protocol::Handle qw[ensureValidHandle ensureValidHandleRegex];
  4         11  
  4         432  
7 4     4   2026 use At::Protocol::NSID qw[ensureValidNsid ensureValidNsidRegex];
  4         11  
  4         337  
8 4     4   25 use feature 'try';
  4         8  
  4         588  
9 4     4   24 use parent -norequire => 'Exporter';
  4         6  
  4         18  
10             use overload
11 14     14   2514 '""' => sub ( $s, $u, $q ) {
  14         16  
  14         12  
  14         12  
  14         11  
12 14         27 $s->as_string;
13 4     4   435 };
  4         6  
  4         33  
14             our %EXPORT_TAGS = ( all => [ our @EXPORT_OK = qw[ensureValidAtUri ensureValidAtUriRegex] ] );
15              
16 149     149 0 213 sub ATP_URI_REGEX () {
  149         221  
17              
18             # proto- --did-------------- --name---------------- --path---- --query-- --hash--
19 149         3314 qr/^(at:\/\/)?((?:did:[a-z0-9:%-]+)|(?:[a-z0-9][a-z0-9.:-]*))(\/[^?#\s]*)?(\?[^#\s]+)?(#[^\s]+)?$/i;
20             }
21              
22 90     90 0 144 sub RELATIVE_REGEX () {
  90         188  
23              
24             # --path----- --query-- --hash--
25 90         848 qr/^(\/[^?#\s]*)?(\?[^#\s]+)?(#[^\s]+)?$/i;
26             }
27              
28 149     149 1 585794 sub new( $class, $uri, $base //= () ) {
  149         284  
  149         263  
  149         255  
  149         186  
29 149         193 my $parsed;
30 149 100       431 if ( defined $base ) {
31 90         275 $parsed = _parse($base);
32 90   33     286 $parsed // throw InvalidAtUriError( 'Invalid AT URI: ' . $base );
33 90         248 my $relativep = _parseRelative($uri);
34 90   33     218 $relativep // throw InvalidAtUriError( 'Invalid path: ' . $uri );
35 90         769 %$parsed = ( %$parsed, %$relativep );
36             }
37             else {
38 59         149 $parsed = _parse($uri);
39 59   33     168 $parsed // throw InvalidAtUriError( 'Invalid AT URI: ' . $uri );
40             }
41 149         1422 bless $parsed, $class;
42             }
43              
44 149     149   285 sub _parse($uri) {
  149         194  
  149         229  
45 149         433 my @res = $uri =~ ATP_URI_REGEX();
46 149 50       726 @res or return;
47 149   100     1575 { hash => $res[4] // '', host => $res[1] // '', pathname => $res[2] // '', searchParams => At::Protocol::URI::_query->new( $res[3] // '' ) };
      50        
      100        
      100        
48             }
49              
50 90     90   179 sub _parseRelative($uri) {
  90         136  
  90         231  
51 90         347 my @res = $uri =~ RELATIVE_REGEX();
52 90 50       310 @res or return;
53 90   100     673 { hash => $res[2] // '', pathname => $res[0] // '', searchParams => At::Protocol::URI::_query->new( $res[1] // '' ) };
      100        
      100        
54             }
55              
56 14     14 0 8 sub as_string($s) {
  14         15  
  14         10  
57 14   50     20 my $path = $s->pathname // '';
58 14 100       35 $path = '/' . $path if $path !~ m[^/];
59 14         21 my $qs = $s->search;
60 14 100 66     29 $qs = '?' . $qs if length $qs && $qs !~ m[^\?];
61 14         102 my $hash = $s->hash;
62 14 100 100     33 $hash = '#' . $hash if length $hash && $hash !~ m[^#];
63 14         20 join '', grep {defined} $s->origin, $path, $qs, $hash;
  56         75  
64             }
65              
66 0     0 1 0 sub create ( $handle_r_did, $collection //= (), $rkey //= () ) {
  0         0  
  0         0  
  0         0  
  0         0  
67 0         0 At::Protocol::URI->new( join '/', grep {defined} $handle_r_did, $collection, $rkey );
  0         0  
68             }
69 382     382 1 538 sub protocol ($s) {'at:'}
  382         625  
  382         465  
  382         1186  
70 243     243 1 530 sub origin($s) { $s->protocol . '//' . $s->host }
  243         361  
  243         439  
  243         505  
71 475   100 475 1 1478 sub host ( $s, $v //= () ) { $v // return $s->{host}; $s->{host} = $v }
  475         692  
  475         672  
  475         594  
  475         2608  
  3         7  
72 174   100 174 1 1565 sub pathname( $s, $v //= () ) { $v // return $s->{pathname}; $s->{pathname} = $v }
  174         259  
  174         253  
  174         228  
  174         1036  
  10         18  
73              
74 155     155 1 1093 sub search ( $s, $v //= () ) {
  155         240  
  155         270  
  155         300  
75 155   100     798 $v // return $s->{searchParams};
76 1         6 $s->{searchParams}->parse_params($v);
77             }
78 155   100 155 1 1027 sub hash ( $s, $v //= () ) { $v // return $s->{hash}; $s->{hash} = $v; }
  155         259  
  155         257  
  155         206  
  155         899  
  2         5  
79              
80 5     5 1 808 sub collection ( $s, $v //= () ) {
  5         6  
  5         8  
  5         5  
81 5 100 100     18 return [ grep {length} split '/', $s->pathname ]->[0] || '' unless defined $v;
82 2         6 my @parts = split '/', $s->pathname;
83 2         4 $parts[0] = $v;
84 2         8 $s->pathname( join '/', @parts );
85             }
86              
87 6     6 1 234 sub rkey ( $s, $v //= () ) {
  6         50  
  6         10  
  6         6  
88 6 100 100     19 return [ grep {length} split '/', $s->pathname ]->[1] || '' unless defined $v;
89 3         5 my @parts = split '/', $s->pathname;
90 3   100     10 $parts[0] //= 'undefined';
91 3         4 $parts[1] = $v;
92 3         63 $s->pathname( join '/', @parts );
93             }
94              
95             #~ Validation utils from https://github.com/bluesky-social/atproto/blob/main/packages/syntax/src/aturi_validation.ts
96             #~ Human-readable constraints on ATURI:
97             #~ - following regular URLs, a 8KByte hard total length limit
98             #~ - follows ATURI docs on website
99             #~ - all ASCII characters, no whitespace. non-ASCII could be URL-encoded
100             #~ - starts "at://"
101             #~ - "authority" is a valid DID or a valid handle
102             #~ - optionally, follow "authority" with "/" and valid NSID as start of path
103             #~ - optionally, if NSID given, follow that with "/" and rkey
104             #~ - rkey path component can include URL-encoded ("percent encoded"), or:
105             #~ ALPHA / DIGIT / "-" / "." / "_" / "~" / ":" / "@" / "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
106             #~ [a-zA-Z0-9._~:@!$&'\(\)*+,;=-]
107             #~ - rkey must have at least one char
108             #~ - regardless of path component, a fragment can follow as "#" and then a JSON pointer (RFC-6901)
109 192     192 1 660664 sub ensureValidAtUri($uri) {
  192         428  
  192         245  
110 192         422 my $fragmentPart;
111 192         787 my @uriParts = split '#', $uri, -1; # negative limit, ftw
112 192 100       892 throw InvalidAtUriError('ATURI can have at most one "#", separating fragment out') if scalar @uriParts > 2;
113 188         410 $fragmentPart = $uriParts[1];
114 188         377 $uri = $uriParts[0];
115              
116             # Check that all chars are boring ASCII
117 188 100       1291 throw InvalidAtUriError('Disallowed characters in ATURI (ASCII)') unless $uri =~ /^[a-zA-Z0-9._~:@!\$&')(*+,;=%\/-]*$/;
118             #
119 177         577 my @parts = split /\//, $uri, -1; # negative limit, ftw
120 177 100 100     1441 throw InvalidAtUriError('ATURI must start with "at://"') if scalar @parts >= 3 && ( $parts[0] ne 'at:' || length $parts[1] );
      100        
121 163 100       558 throw InvalidAtUriError('ATURI requires at least method and authority sections') if scalar @parts < 3;
122 156         404 try {
123 156 100       748 if ( $parts[2] =~ m/^did:/ ) { ensureValidDid( $parts[2] ); }
  125         696  
124 31         156 else { ensureValidHandle( $parts[2] ) }
125             }
126             catch ($err) {
127 24         98 throw InvalidAtUriError('ATURI authority must be a valid handle or DID');
128             };
129 132 100       497 if ( scalar @parts >= 4 ) {
130 110 100       347 if ( !length $parts[3] ) {
131 8         41 throw InvalidAtUriError('ATURI can not have a slash after authority without a path segment');
132             }
133 102         187 try {
134 102         501 ensureValidNsid( $parts[3] );
135             }
136             catch ($err) {
137 18         79 throw InvalidAtUriError('ATURI requires first path segment (if supplied) to be valid NSID')
138             }
139             }
140 106 100       289 if ( scalar @parts >= 5 ) {
141 69 100       199 throw InvalidAtUriError('ATURI can not have a slash after collection, unless record key is provided') if !length $parts[4]
142              
143             # would validate rkey here, but there are basically no constraints!
144             }
145 102 100       259 throw InvalidAtUriError('ATURI path can have at most two parts, and no trailing slash') if scalar @parts >= 6;
146 94 50 66     338 throw InvalidAtUriError('ATURI fragment must be non-empty and start with slash') if scalar @uriParts >= 2 && !defined $fragmentPart;
147 94 100       225 if ( defined $fragmentPart ) {
148 24 100 100     168 throw InvalidAtUriError('ATURI fragment must be non-empty and start with slash')
149             if length $fragmentPart == 0 || substr( $fragmentPart, 0, 1 ) ne '/';
150              
151             # NOTE: enforcing *some* checks here for sanity. Eg, at least no whitespace
152 18 100       84 throw InvalidAtUriError( 'Disallowed characters in ATURI fragment (ASCII)' . $fragmentPart )
153             if $fragmentPart !~ /^\/[a-zA-Z0-9._~:@!\$&')(*+,;=%[\]\/-]*$/;
154             }
155 84 100       236 throw InvalidAtUriError('ATURI is far too long') if length $uri > 8 * 1024;
156 82         490 1;
157             }
158              
159 192     192 1 82943 sub ensureValidAtUriRegex($uri) {
  192         357  
  192         357  
160              
161             #~ simple regex to enforce most constraints via just regex and length.
162 192         1021 my $aturiRegex
163             = qr/^at:\/\/(?[a-zA-Z0-9._:%-]+)(\/(?[a-zA-Z0-9-.]+)(\/(?[a-zA-Z0-9._~:@!\$&%')(*+,;=-]+))?)?(#(?\/[a-zA-Z0-9._~:@!\$&%')(*+,;=\-[\]\/\\]*))?$/;
164 192         2861 my ($rm) = $uri =~ $aturiRegex;
165 192 100 66     2151 throw InvalidAtUriError(q[ATURI didn't validate via regex]) if !$rm || !keys %+;
166 108         1771 my %groups = %+;
167 108         399 try {
168             ensureValidHandleRegex( $groups{authority} )
169 108         521 }
170             catch ($err) {
171 103         252 try {
172             ensureValidDidRegex( $groups{authority} )
173 103         403 }
174             catch ($err) {
175 18         86 throw InvalidAtUriError('ATURI authority must be a valid handle or DID')
176             }
177             }
178 90 100       253 if ( defined $groups{collection} ) {
179 70         123 try {
180             ensureValidNsidRegex( $groups{collection} )
181 70         238 }
182             catch ($err) {
183 6         23 throw InvalidAtUriError('ATURI collection path segment must be a valid NSID');
184             }
185             }
186 84 100       201 throw InvalidAtUriError('ATURI is far too long') if length $uri > 8 * 1024;
187 82         505 1;
188             }
189              
190             # fatal error
191             register 'InvalidAtUriError', 1;
192             };
193             package #
194             At::Protocol::URI::_query 1.0 {
195 4     4   14454 use v5.42;
  4         15  
196 4     4   621 use URI::Escape qw[uri_escape_utf8 uri_unescape];
  4         2235  
  4         481  
197             use overload
198 171     171   38227 '""' => sub ( $s, $u, $q ) {
  171         263  
  171         201  
  171         234  
  171         263  
199 171         586 $s->as_string;
200 4     4   25 };
  4         7  
  4         33  
201              
202 240     240   301903 sub new( $class, $qs ) {
  240         344  
  240         313  
  240         296  
203 240         517 my $s = bless [], $class;
204 240         678 $s->parse_params($qs);
205 240         2208 $s;
206             }
207              
208 241     241   282 sub parse_params( $s, $qs ) {
  241         306  
  241         312  
  241         291  
209 241         727 $qs =~ s[^\?+][]; # Just in case
210             @$s = map {
211 241         857 [ map { uri_unescape($_) } split /=/, $_, 2 ]
  130         606  
  260         1561  
212             } split /[&;]/, $qs;
213             }
214              
215 2     2   695 sub get_param( $s, $name ) {
  2         4  
  2         4  
  2         4  
216 2         10 map { $_->[1] } grep { $_->[0] eq $name } @$s;
  4         16  
  4         11  
217             }
218              
219 2     2   1252 sub add_param( $s, $name, @v ) {
  2         4  
  2         4  
  2         4  
  2         3  
220 2         11 $name = uri_unescape $name;
221 2         44 push @$s, [ $name, uri_unescape shift @v ] while @v;
222 2         27 1;
223             }
224              
225 5     5   1343 sub set_param( $s, $name, @v ) {
  5         8  
  5         11  
  5         11  
  5         6  
226 5         15 $name = uri_unescape $name;
227 5         53 for my $slot ( grep { $_->[0] eq $name } @$s ) {
  8         23  
228 4         12 $slot->[1] = uri_unescape shift @v;
229 4 100       37 @v || last;
230             }
231 5         36 push @$s, [ $name, uri_unescape shift @v ] while @v;
232 5         59 1;
233             }
234              
235 2     2   378 sub delete_param( $s, $name ) {
  2         2  
  2         3  
  2         1  
236 2         6 $name = uri_unescape $name;
237 2         19 @$s = grep { $_->[0] ne $name } @$s;
  7         21  
238             }
239              
240 1     1   2 sub replace_param( $s, $name, @v ) {
  1         2  
  1         3  
  1         2  
  1         2  
241 1         4 $s->delete_param($name);
242 1         3 $name = uri_unescape $name;
243 1         13 push @$s, [ $name, uri_unescape shift @v ] while @v;
244 1         15 1;
245             }
246              
247 1     1   3 sub reset($s) {
  1         2  
  1         3  
248 1         6 !( @$s = () );
249             }
250              
251 179     179   286 sub as_string( $s, $sep //= '&' ) {
  179         252  
  179         314  
  179         220  
252 179         752 join $sep, map { join '=', uri_escape_utf8( $_->[0] ), uri_escape_utf8( $_->[1] ) } @$s;
  102         1327  
253             }
254             };
255             1;
256             __END__