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   205524 use v5.42;
  4         15  
3 4     4   20 no warnings qw[experimental::builtin experimental::try];
  4         7  
  4         217  
4 4     4   342 use At::Error qw[register throw];
  4         7  
  4         20  
5 4     4   1879 use At::Protocol::DID qw[ensureValidDid ensureValidDidRegex];
  4         9  
  4         319  
6 4     4   1779 use At::Protocol::Handle qw[ensureValidHandle ensureValidHandleRegex];
  4         14  
  4         399  
7 4     4   2009 use At::Protocol::NSID qw[ensureValidNsid ensureValidNsidRegex];
  4         13  
  4         357  
8 4     4   31 use feature 'try';
  4         5  
  4         510  
9 4     4   22 use parent -norequire => 'Exporter';
  4         8  
  4         18  
10             use overload
11 14     14   2990 '""' => sub ( $s, $u, $q ) {
  14         18  
  14         15  
  14         15  
  14         12  
12 14         45 $s->as_string;
13 4     4   412 };
  4         8  
  4         30  
14             our %EXPORT_TAGS = ( all => [ our @EXPORT_OK = qw[ensureValidAtUri ensureValidAtUriRegex] ] );
15              
16 149     149 0 418 sub ATP_URI_REGEX () {
  149         188  
17              
18             # proto- --did-------------- --name---------------- --path---- --query-- --hash--
19 149         2985 qr/^(at:\/\/)?((?:did:[a-z0-9:%-]+)|(?:[a-z0-9][a-z0-9.:-]*))(\/[^?#\s]*)?(\?[^#\s]+)?(#[^\s]+)?$/i;
20             }
21              
22 90     90 0 131 sub RELATIVE_REGEX () {
  90         124  
23              
24             # --path----- --query-- --hash--
25 90         948 qr/^(\/[^?#\s]*)?(\?[^#\s]+)?(#[^\s]+)?$/i;
26             }
27              
28 149     149 1 639613 sub new( $class, $uri, $base //= () ) {
  149         353  
  149         308  
  149         268  
  149         223  
29 149         195 my $parsed;
30 149 100       464 if ( defined $base ) {
31 90         336 $parsed = _parse($base);
32 90   33     292 $parsed // throw InvalidAtUriError( 'Invalid AT URI: ' . $base );
33 90         247 my $relativep = _parseRelative($uri);
34 90   33     310 $relativep // throw InvalidAtUriError( 'Invalid path: ' . $uri );
35 90         895 %$parsed = ( %$parsed, %$relativep );
36             }
37             else {
38 59         218 $parsed = _parse($uri);
39 59   33     153 $parsed // throw InvalidAtUriError( 'Invalid AT URI: ' . $uri );
40             }
41 149         1363 bless $parsed, $class;
42             }
43              
44 149     149   269 sub _parse($uri) {
  149         235  
  149         164  
45 149         511 my @res = $uri =~ ATP_URI_REGEX();
46 149 50       796 @res or return;
47 149   100     1735 { 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   135 sub _parseRelative($uri) {
  90         185  
  90         169  
51 90         273 my @res = $uri =~ RELATIVE_REGEX();
52 90 50       330 @res or return;
53 90   100     814 { hash => $res[2] // '', pathname => $res[0] // '', searchParams => At::Protocol::URI::_query->new( $res[1] // '' ) };
      100        
      100        
54             }
55              
56 14     14 0 17 sub as_string($s) {
  14         15  
  14         15  
57 14   50     61 my $path = $s->pathname // '';
58 14 100       44 $path = '/' . $path if $path !~ m[^/];
59 14         26 my $qs = $s->search;
60 14 100 66     39 $qs = '?' . $qs if length $qs && $qs !~ m[^\?];
61 14         136 my $hash = $s->hash;
62 14 100 100     40 $hash = '#' . $hash if length $hash && $hash !~ m[^#];
63 14         30 join '', grep {defined} $s->origin, $path, $qs, $hash;
  56         87  
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 604 sub protocol ($s) {'at:'}
  382         553  
  382         535  
  382         1443  
70 243     243 1 743 sub origin($s) { $s->protocol . '//' . $s->host }
  243         405  
  243         277  
  243         527  
71 475   100 475 1 1752 sub host ( $s, $v //= () ) { $v // return $s->{host}; $s->{host} = $v }
  475         692  
  475         710  
  475         523  
  475         2757  
  3         7  
72 174   100 174 1 1708 sub pathname( $s, $v //= () ) { $v // return $s->{pathname}; $s->{pathname} = $v }
  174         287  
  174         367  
  174         264  
  174         1247  
  10         24  
73              
74 155     155 1 1068 sub search ( $s, $v //= () ) {
  155         257  
  155         323  
  155         181  
75 155   100     871 $v // return $s->{searchParams};
76 1         6 $s->{searchParams}->parse_params($v);
77             }
78 155   100 155 1 1663 sub hash ( $s, $v //= () ) { $v // return $s->{hash}; $s->{hash} = $v; }
  155         291  
  155         259  
  155         197  
  155         899  
  2         9  
79              
80 5     5 1 696 sub collection ( $s, $v //= () ) {
  5         9  
  5         9  
  5         5  
81 5 100 100     20 return [ grep {length} split '/', $s->pathname ]->[0] || '' unless defined $v;
82 2         5 my @parts = split '/', $s->pathname;
83 2         3 $parts[0] = $v;
84 2         6 $s->pathname( join '/', @parts );
85             }
86              
87 6     6 1 247 sub rkey ( $s, $v //= () ) {
  6         8  
  6         9  
  6         7  
88 6 100 100     17 return [ grep {length} split '/', $s->pathname ]->[1] || '' unless defined $v;
89 3         41 my @parts = split '/', $s->pathname;
90 3   100     16 $parts[0] //= 'undefined';
91 3         5 $parts[1] = $v;
92 3         12 $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 912878 sub ensureValidAtUri($uri) {
  192         435  
  192         300  
110 192         697 my $fragmentPart;
111 192         848 my @uriParts = split '#', $uri, -1; # negative limit, ftw
112 192 100       813 throw InvalidAtUriError('ATURI can have at most one "#", separating fragment out') if scalar @uriParts > 2;
113 188         507 $fragmentPart = $uriParts[1];
114 188         464 $uri = $uriParts[0];
115              
116             # Check that all chars are boring ASCII
117 188 100       1369 throw InvalidAtUriError('Disallowed characters in ATURI (ASCII)') unless $uri =~ /^[a-zA-Z0-9._~:@!\$&')(*+,;=%\/-]*$/;
118             #
119 177         698 my @parts = split /\//, $uri, -1; # negative limit, ftw
120 177 100 100     1450 throw InvalidAtUriError('ATURI must start with "at://"') if scalar @parts >= 3 && ( $parts[0] ne 'at:' || length $parts[1] );
      100        
121 163 100       545 throw InvalidAtUriError('ATURI requires at least method and authority sections') if scalar @parts < 3;
122 156         395 try {
123 156 100       586 if ( $parts[2] =~ m/^did:/ ) { ensureValidDid( $parts[2] ); }
  125         2056  
124 31         158 else { ensureValidHandle( $parts[2] ) }
125             }
126             catch ($err) {
127 24         124 throw InvalidAtUriError('ATURI authority must be a valid handle or DID');
128             };
129 132 100       428 if ( scalar @parts >= 4 ) {
130 110 100       347 if ( !length $parts[3] ) {
131 8         44 throw InvalidAtUriError('ATURI can not have a slash after authority without a path segment');
132             }
133 102         236 try {
134 102         474 ensureValidNsid( $parts[3] );
135             }
136             catch ($err) {
137 18         70 throw InvalidAtUriError('ATURI requires first path segment (if supplied) to be valid NSID')
138             }
139             }
140 106 100       337 if ( scalar @parts >= 5 ) {
141 69 100       216 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       322 throw InvalidAtUriError('ATURI path can have at most two parts, and no trailing slash') if scalar @parts >= 6;
146 94 50 66     364 throw InvalidAtUriError('ATURI fragment must be non-empty and start with slash') if scalar @uriParts >= 2 && !defined $fragmentPart;
147 94 100       262 if ( defined $fragmentPart ) {
148 24 100 100     148 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       99 throw InvalidAtUriError( 'Disallowed characters in ATURI fragment (ASCII)' . $fragmentPart )
153             if $fragmentPart !~ /^\/[a-zA-Z0-9._~:@!\$&')(*+,;=%[\]\/-]*$/;
154             }
155 84 100       197 throw InvalidAtUriError('ATURI is far too long') if length $uri > 8 * 1024;
156 82         470 1;
157             }
158              
159 192     192 1 100511 sub ensureValidAtUriRegex($uri) {
  192         455  
  192         341  
160              
161             #~ simple regex to enforce most constraints via just regex and length.
162 192         883 my $aturiRegex
163             = qr/^at:\/\/(?[a-zA-Z0-9._:%-]+)(\/(?[a-zA-Z0-9-.]+)(\/(?[a-zA-Z0-9._~:@!\$&%')(*+,;=-]+))?)?(#(?\/[a-zA-Z0-9._~:@!\$&%')(*+,;=\-[\]\/\\]*))?$/;
164 192         3342 my ($rm) = $uri =~ $aturiRegex;
165 192 100 66     2133 throw InvalidAtUriError(q[ATURI didn't validate via regex]) if !$rm || !keys %+;
166 108         1806 my %groups = %+;
167 108         474 try {
168             ensureValidHandleRegex( $groups{authority} )
169 108         575 }
170             catch ($err) {
171 103         272 try {
172             ensureValidDidRegex( $groups{authority} )
173 103         426 }
174             catch ($err) {
175 18         75 throw InvalidAtUriError('ATURI authority must be a valid handle or DID')
176             }
177             }
178 90 100       329 if ( defined $groups{collection} ) {
179 70         167 try {
180             ensureValidNsidRegex( $groups{collection} )
181 70         245 }
182             catch ($err) {
183 6         31 throw InvalidAtUriError('ATURI collection path segment must be a valid NSID');
184             }
185             }
186 84 100       211 throw InvalidAtUriError('ATURI is far too long') if length $uri > 8 * 1024;
187 82         527 1;
188             }
189              
190             # fatal error
191             register 'InvalidAtUriError', 1;
192             };
193             package #
194             At::Protocol::URI::_query 1.0 {
195 4     4   15681 use v5.42;
  4         16  
196 4     4   716 use URI::Escape qw[uri_escape_utf8 uri_unescape];
  4         2442  
  4         587  
197             use overload
198 171     171   42874 '""' => sub ( $s, $u, $q ) {
  171         294  
  171         321  
  171         276  
  171         203  
199 171         632 $s->as_string;
200 4     4   34 };
  4         9  
  4         49  
201              
202 240     240   305342 sub new( $class, $qs ) {
  240         379  
  240         563  
  240         267  
203 240         600 my $s = bless [], $class;
204 240         741 $s->parse_params($qs);
205 240         2408 $s;
206             }
207              
208 241     241   341 sub parse_params( $s, $qs ) {
  241         319  
  241         407  
  241         300  
209 241         767 $qs =~ s[^\?+][]; # Just in case
210             @$s = map {
211 241         1033 [ map { uri_unescape($_) } split /=/, $_, 2 ]
  130         745  
  260         1691  
212             } split /[&;]/, $qs;
213             }
214              
215 2     2   666 sub get_param( $s, $name ) {
  2         3  
  2         4  
  2         3  
216 2         10 map { $_->[1] } grep { $_->[0] eq $name } @$s;
  4         16  
  4         10  
217             }
218              
219 2     2   1583 sub add_param( $s, $name, @v ) {
  2         7  
  2         5  
  2         5  
  2         2  
220 2         8 $name = uri_unescape $name;
221 2         28 push @$s, [ $name, uri_unescape shift @v ] while @v;
222 2         34 1;
223             }
224              
225 5     5   1361 sub set_param( $s, $name, @v ) {
  5         10  
  5         8  
  5         12  
  5         8  
226 5         14 $name = uri_unescape $name;
227 5         63 for my $slot ( grep { $_->[0] eq $name } @$s ) {
  8         50  
228 4         11 $slot->[1] = uri_unescape shift @v;
229 4 100       36 @v || last;
230             }
231 5         22 push @$s, [ $name, uri_unescape shift @v ] while @v;
232 5         57 1;
233             }
234              
235 2     2   687 sub delete_param( $s, $name ) {
  2         4  
  2         3  
  2         3  
236 2         7 $name = uri_unescape $name;
237 2         21 @$s = grep { $_->[0] ne $name } @$s;
  7         25  
238             }
239              
240 1     1   3 sub replace_param( $s, $name, @v ) {
  1         3  
  1         2  
  1         3  
  1         3  
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         3  
  1         2  
248 1         6 !( @$s = () );
249             }
250              
251 179     179   354 sub as_string( $s, $sep //= '&' ) {
  179         318  
  179         354  
  179         277  
252 179         923 join $sep, map { join '=', uri_escape_utf8( $_->[0] ), uri_escape_utf8( $_->[1] ) } @$s;
  102         1626  
253             }
254             };
255             1;
256             __END__