File Coverage

bin/gmitool
Criterion Covered Total %
statement 184 211 87.2
branch 77 110 70.0
condition 22 37 59.4
subroutine 22 24 91.6
pod n/a
total 305 382 79.8


line stmt bran cond sub pod time code
1             #!perl
2             # gmitool - a utility for gemini related things, part of Net::Gemini
3 23     23   126398 use strict;
  23         42  
  23         972  
4 23     23   104 use warnings;
  23         73  
  23         1578  
5 23     23   25047 use Cpanel::JSON::XS qw(decode_json encode_json);
  23         175467  
  23         2308  
6 23     23   14583 use Encode qw(decode);
  23         458030  
  23         2618  
7 23     23   242 use File::Path qw(make_path);
  23         51  
  23         1978  
8 23     23   11650 use File::Slurper qw(read_text write_text);
  23         83086  
  23         2086  
9 23     23   11437 use File::Spec::Functions qw(catdir catfile splitpath);
  23         21286  
  23         2187  
10 23     23   17524 use Getopt::Long qw(GetOptionsFromArray);
  23         315909  
  23         198  
11 23     23   18642 use IO::Socket qw[PF_INET PF_INET6];
  23         601889  
  23         120  
12 23     23   21794 use Net::Gemini 0.10;
  23         657  
  23         1692  
13 23     23   205 use Parse::MIME 'parse_mime_type';
  23         72  
  23         1140  
14 23     23   170 use URI ();
  23         42  
  23         2802426  
15              
16 23         1939928 Getopt::Long::Configure('bundling');
17              
18 23         1302 my $Address_Family; # v4 or v6 or (defaults to) any
19             my $Allow_Verified; # allow verified certificates
20 23         0 my $Be_Quiet;
21 23         0 my $Force_Update; # clobber known_hosts?
22 23         0 my $Hosts_Dirty; # do we need to update known_hosts?
23 23         0 my $Known_Hosts; # hashref, from/to JSON
24 23         0 my $Show_Status; # log various things to stderr
25              
26 23 100       110 die "gmitool: command [args ..]\n" unless @ARGV;
27              
28 22         209 my %commands = ( file => \&file, get => \&get, link => \&link );
29              
30 22         84 my $cmd = shift;
31 22 100       161 if ( $cmd =~ m{^gemini://} ) {
    100          
32 1         3 unshift @ARGV, $cmd;
33 1         2 $cmd = 'get';
34             } elsif ( !exists $commands{$cmd} ) {
35 1         0 die "gmitool: no such command '$cmd' (",
36             join( ' ', sort keys %commands ),
37             ")\n";
38             }
39 21         149 exit $commands{$cmd}->( \@ARGV );
40              
41             # puts file name(s) into a target file
42             sub file {
43 3     3   10 my ($args) = @_;
44 3 50       18 GetOptionsFromArray( $args, 'f=s', \my $target ) or exit 64;
45 3   100     2453 $target //= 'index.gmi';
46 3 50       654 open my $fh, '>>', $target or die "gmitool: open '$target': $!\n";
47 3         13 my $exit = 0;
48 3         31 for my $n (@$args) {
49 4 100       1507 if (-f $n) {
    100          
50 1         6 print $fh "=> $n\n";
51             } elsif (-d $n) {
52 2         8 $n =~ s{/+$}{};
53 2         11 print $fh "=> $n/\n";
54             } else {
55 1         22 warn "gmitool: unknown file '$n'\n";
56 1         4 $exit = 1;
57             }
58             }
59 3         0 return $exit;
60             }
61              
62             # gets a gemini page
63             sub get {
64 13     13   39 my ($args) = @_;
65             GetOptionsFromArray(
66             $args,
67 0     0   0 4 => sub { $Address_Family = PF_INET },
68 0     0   0 6 => sub { $Address_Family = PF_INET6 },
69 13 100       218 A => \$Allow_Verified,
70             'C=s' => \my $client_cert,
71             'E=s' => \my $output_encoding,
72             'H=s' => \my $sni_host,
73             'K=s' => \my $client_key,
74             S => \$Show_Status,
75             'V=s' => \my $verify,
76             f => \$Force_Update,
77             'links|l' => \my $Only_Links,
78             q => \$Be_Quiet,
79             't=i', => \my $timeout
80             ) or exit 64;
81              
82 12         19665 my $resource = $args->[0];
83 12 100 100     76 die "Usage: gmitool get [options] url\n"
84             unless defined $resource and length $resource;
85              
86             # meta is UTF-8 and may appear in STDERR
87             # KLUGE this assumes that UTF-8 is correct; maybe this could also
88             # use $output_encoding if that is set? (or remove output encoding
89             # and insist on things being UTF-8)
90 9         238 binmode *STDERR, ':encoding(UTF-8)';
91              
92             # default if -E not specified and content has a charset
93 9 100       1117 $output_encoding = ':encoding(UTF-8)' unless defined $output_encoding;
94              
95 9         84 my ( $known_hosts_file, %param, @unveils );
96              
97 9 100       67 if ( defined $client_cert ) {
98 1         5 $param{ssl}->{SSL_cert_file} = $client_cert;
99 1         5 push @unveils, [ $client_cert, 'r' ];
100             }
101 9 100       33 if ( defined $client_key ) {
102 1         4 $param{ssl}->{SSL_key_file} = $client_key;
103 1         3 push @unveils, [ $client_key, 'r' ];
104             }
105 9 100       28 if ( defined $sni_host ) {
106 1         4 $param{ssl}->{SSL_hostname} = $sni_host;
107             }
108 9 50       44 $param{family} = $Address_Family if defined $Address_Family;
109 9   100     77 $param{ssl}->{Timeout} = $timeout || 30;
110              
111 9 100       34 if ( defined $verify ) {
112 2 100       7 if ( $verify eq 'peer' ) {
    50          
113 1         4 $param{ssl}->{SSL_verify_mode} = 1; # SSL_VERIFY_PEER
114 1         4 $param{ssl}->{SSL_verify_callback} = undef;
115             } elsif ( $verify eq 'none' ) {
116 1 50       3 warn "NOTICE no certificate verification\n" if $Show_Status;
117 1         2 $param{ssl}->{SSL_verify_mode} = 0; # SSL_VERIFY_NONE
118 1         3 $param{ssl}->{SSL_verify_callback} = undef;
119             } else {
120 0         0 die "gmitool: unknown verify mode '$verify'\n";
121             }
122             } else {
123 7         15 my $cache_dir;
124 7 50       26 if ( length $ENV{GMITOOL_HOSTS} ) {
125 7         48 $cache_dir = ( splitpath( $ENV{GMITOOL_HOSTS} ) )[1];
126 7         160 $known_hosts_file = $ENV{GMITOOL_HOSTS};
127             } else {
128 0 0       0 die "gmitool: HOME is not set" unless defined $ENV{HOME};
129 0         0 $cache_dir = catdir( $ENV{HOME}, qw{.cache gmitool} );
130 0         0 $known_hosts_file = catfile( $cache_dir, 'known_hosts' );
131             }
132 7         766 make_path($cache_dir);
133 7         20 my $buf;
134 7         14 eval { $buf = read_text($known_hosts_file) };
  7         48  
135 7 100 66     1454 $Known_Hosts = decode_json($buf) if defined $buf and length $buf;
136 7         29 push @unveils, [ $cache_dir, 'cw' ];
137 7         77 @param{qw(tofu verify_ssl)} = ( 1, \&verify_ssl );
138             }
139              
140 9         20 my $pledge = eval {
141 9         1559 require OpenBSD::Pledge;
142 0         0 require OpenBSD::Unveil;
143 0         0 OpenBSD::Unveil->import;
144 0         0 1;
145             };
146 9 50       82 if ($pledge) {
147 0         0 OpenBSD::Pledge::pledge(qw{cpath dns inet rpath unveil wpath});
148 0 0       0 unveil( $ENV{SSL_CERT_DIR}, 'r' ) if exists $ENV{SSL_CERT_DIR};
149 0 0       0 unveil( $ENV{SSL_CERT_FILE}, 'r' ) if exists $ENV{SSL_CERT_FILE};
150 0         0 unveil(qw{/etc/ssl r});
151 0         0 for my $dir (@INC) { unveil( $dir, 'r' ) }
  0         0  
152 0         0 for my $pathperm (@unveils) { unveil(@$pathperm) }
  0         0  
153 0         0 unveil();
154             }
155              
156 9         31 my ( $gem, $code );
157 9         16 my $redirects = -1;
158              
159 11         175 REQUEST:
160             ( $gem, $code ) = Net::Gemini->get( $resource, %param );
161 10 50 33     152 if ( $pledge and $code != 3 ) {
162 0         0 OpenBSD::Pledge::pledge(qw{cpath rpath wpath});
163             }
164 10 100       2282 if ( $code == 2 ) {
    50          
    50          
    0          
    0          
    0          
165 8 100       34 warn "META " . $gem->meta . "\n" if $Show_Status;
166 8         46 my ( $type, $sub, $pr ) = parse_mime_type( $gem->meta );
167 8         578 my ( $encoded, $charset ) = is_encoded($pr);
168 8 50 33     84 $encoded = 0 if defined $output_encoding and $output_encoding eq '';
169 8 100       429 if ($Only_Links) {
170 1 50 33     5 if ( $type eq 'text' and $sub eq 'gemini' ) {
171 1         3 show_links($gem);
172 1         182 goto CLEANUP;
173             }
174             }
175 7 100       84 if ($encoded) {
176 2         4 my $body = '';
177 2     2   23 $gem->getmore( sub { $body .= $_[0]; 1 } );
  2         5  
  2         5  
178 2 50       605 binmode STDOUT, $output_encoding if defined $output_encoding;
179 2         99 print decode( $charset, $body, Encode::FB_CROAK );
180             } else {
181 5         133 binmode STDOUT, ':raw'; # garbage in, garbage out
182 5     5   65 $gem->getmore( sub { syswrite STDOUT, $_[0]; 1 } );
  5         238  
  5         23  
183             }
184             } elsif ( $code == 0 ) {
185 0         0 my $e = $gem->error;
186 0         0 chomp $e;
187 0         0 die "gmitool: error: $e\n";
188             } elsif ( $code == 3 ) {
189 2 50       9 die "gmitool: too many redirects ($redirects) " . $gem->meta . "\n"
190             if ++$redirects >= 5; # amfora also uses 5 max redirects
191 2         13 my $new = $gem->meta;
192 2         21 $resource = URI->new_abs( $new, $gem->{_uri} );
193 2 100       968 warn "REDIRECT " . $resource . "\n" if $Show_Status;
194 2         2000978 sleep 1; # don't be too quick about a loop
195 2         106 goto REQUEST;
196             } elsif ( $code == 4 ) {
197 0         0 die 'gmitool: temporary-failure '
198             . $gem->status . ' '
199             . $gem->meta . "\n";
200             } elsif ( $code == 5 ) {
201 0         0 die 'gmitool: permanent-failure '
202             . $gem->status . ' '
203             . $gem->meta . "\n";
204             } elsif ( $code == 6 ) {
205 0         0 die 'gmitool: client-certificate '
206             . $gem->status . ' '
207             . $gem->meta . "\n";
208             }
209              
210             CLEANUP:
211 8 100       1227 if ($Hosts_Dirty) {
212 2         46 write_text( $known_hosts_file, encode_json($Known_Hosts) );
213             }
214              
215 8         15 return 0;
216             }
217              
218             # is it encoded in something besides UTF-8, or also besides US-ASCII of
219             # which UTF-8 is a superset?
220             sub is_encoded {
221 8     8   21 my ($pr) = @_;
222 8 100       35 if ( exists $pr->{charset} ) {
223             # TODO can US-ASCII appear in any other forms (and how likely
224             # are we to see them, and would it even cause a problem?
225             return 1, $pr->{charset}
226             unless $pr->{charset} =~ m/(?i)utf-8/
227 7 100 66     106 or $pr->{charset} =~ m/(?i)ascii/;
228             } else {
229             # a server might return 'CHARSET' or maybe 'Charset' or whatever
230             # according to the gemini torture tests (see t/torture.t), so
231             # look for look for those alternative forms
232 1         3 for my $key (%$pr) {
233 1 50       8 if ( $key =~ m/^(?i)charset$/ ) {
234             return 1, $pr->{$key}
235             unless $pr->{$key} =~ m/(?i)utf-8/
236 1 50 33     11 or $pr->{$key} =~ m/(?i)ascii/;
237             }
238             }
239             }
240 6         21 return 0, 'UTF-8';
241             }
242              
243             # extracts links in text/gemini input
244             sub link {
245 5     5   13 my ($args) = @_;
246 5 100       49 GetOptionsFromArray(
247             $args,
248             'base|b=s' => \my $base,
249             'relative|r' => \my $relative,
250             ) or exit 64;
251 4         2895 while ( my $line = readline ) {
252 6 50       591 if ( $line =~ m/^=>\s*(\S+)/ ) {
253 6 100       60 my $u = defined $base ? URI->new_abs( $1, $base ) : URI->new($1);
254 6 100 100     9485 next if $relative and defined $u->scheme;
255 5         71 print $u->canonical, "\n";
256             }
257             }
258 4         0 return 0;
259             }
260              
261             # parse and qualify links out of what is assumed to be text/gemini
262             # content. links can be followed by an optional description:
263             # =>/about/
264             # => photos/ all the cats
265             sub show_links {
266 1     1   2 my ($gem) = @_;
267 1         2 my $base = $gem->{_uri};
268             # KLUGE in theory may need support encoding, but if we assume that
269             # the links are ASCII with anything fancy encoded, and that the
270             # environment assumes UTF-8 or ASCII...
271 1         5 binmode *STDOUT, ':raw';
272 1         1 my $buf = '';
273 1         1 my $eom;
274             $gem->getmore(
275             sub {
276 1     1   2 $buf .= $_[0];
277 1         2 $eom = 0;
278 1         6 while ( $buf =~ m{^=>[ \t]*(\S+)(?:[ \t]+[^\r\n]*)?[\r\n]}gm ) {
279 2         10 print URI->new_abs( $1, $base )->canonical, "\n";
280 2         59 $eom = $+[0];
281             }
282 1 50       4 substr $buf, 0, $eom, '' if $eom;
283 1         3 1;
284             }
285 1         8 );
286             }
287              
288             # NOTE the host and possibly certificate maybe should be hashed for
289             # privacy, though the benefits of this seem dubious given how few gemini
290             # servers there are and other means of collecting connection information
291             sub verify_ssl {
292 12     12   36 my ($param) = @_;
293 12 50 66     71 return 1 if $Allow_Verified and $param->{okay};
294 11         23 my $key = join '|', @{$param}{qw(host port)};
  11         50  
295 11         59 my $new = { map { $_ => $param->{$_} }
  55         181  
296             qw(digest ip notAfter notBefore okay) };
297             # one could save the whole certificate with something like
298             #$new->{cert} = Net::SSLeay::PEM_get_string_X509($param->{cert});
299 11         91 my @fields = qw{notAfter notBefore ip okay};
300 11 100       71 if ( !exists $Known_Hosts->{$key} ) {
    100          
301 1         4 ( $Known_Hosts->{$key}, $Hosts_Dirty ) = ( $new, 1 );
302             } elsif ($Force_Update) {
303 2 50 33     7 unless ( $Be_Quiet
304             or $Known_Hosts->{$key}{digest} eq $param->{digest} ) {
305 0         0 verify_warn( $key, $Known_Hosts->{$key}, $new, \@fields );
306             }
307             # merge new over old in the event there are unknown keys that
308             # something else added to the cache
309 2         4 my %update = ( %{ $Known_Hosts->{$key} }, %$new );
  2         21  
310 2         10 ( $Known_Hosts->{$key}, $Hosts_Dirty ) = ( \%update, 1 );
311             } else {
312 8 100       37 if ( $Known_Hosts->{$key}{digest} ne $param->{digest} ) {
313 1         5 verify_warn( $key, $Known_Hosts->{$key}, $new, \@fields );
314 1         0 exit 1;
315             }
316             }
317 10         81 return 1;
318             }
319              
320             sub verify_warn {
321 1     1   3 my ( $key, $old, $new, $fields ) = @_;
322 1         32 warn qq(gmitool: digest mismatch "$key"\n);
323 1         3 for my $dtl (@$fields) {
324 4 50 33     17 if ( exists $old->{$dtl} and $old->{$dtl} ne $new->{$dtl} ) {
325 0           warn " $dtl\t$old->{$dtl}\t$new->{$dtl}\n";
326             }
327             }
328             }
329              
330             __END__