File Coverage

lib/Mail/DMARC/Base.pm
Criterion Covered Total %
statement 176 210 83.8
branch 67 108 62.0
condition 12 28 42.8
subroutine 38 40 95.0
pod 7 24 29.1
total 300 410 73.1


line stmt bran cond sub pod time code
1             package Mail::DMARC::Base;
2             our $VERSION = '1.20210927';
3 16     16   7449 use strict;
  16         35  
  16         472  
4 16     16   83 use warnings;
  16         45  
  16         407  
5 16     16   216 use 5.10.0;
  16         54  
6              
7 16     16   95 use Carp;
  16         27  
  16         926  
8 16     16   9341 use Config::Tiny;
  16         17427  
  16         499  
9 16     16   4540 use File::ShareDir;
  16         236621  
  16         796  
10 16     16   11618 use HTTP::Tiny;
  16         597171  
  16         677  
11 16     16   6041 use IO::File;
  16         16924  
  16         2049  
12 16     16   8570 use Net::DNS::Resolver;
  16         908201  
  16         2571  
13 16     16   14058 use Net::IDN::Encode qw/domain_to_unicode/;
  16         1942861  
  16         1506  
14 16     16   10993 use Net::IP;
  16         953393  
  16         3438  
15 16     16   10771 use Regexp::Common 2013031301 qw /net/;
  16         44602  
  16         94  
16 16     16   49608 use Socket;
  16         103  
  16         10244  
17 16     16   8461 use Socket6 qw//; # don't export symbols
  16         19249  
  16         13628  
18              
19             sub new {
20 99     99 0 15124 my ( $class, @args ) = @_;
21 99 50       331 croak "invalid args" if scalar @args % 2 != 0;
22 99         950 return bless {
23             config_file => 'mail-dmarc.ini',
24             @args, # this may override config_file
25             }, $class;
26             }
27              
28             my $_fake_time;
29             sub time { ## no critic
30             # Ability to return a fake time for testing
31 33     33 0 100 my ( $self ) = @_;
32 33 100       157 my $time = defined $Mail::DMARC::Base::_fake_time ? $Mail::DMARC::Base::_fake_time : time;
33 33         648 return $time;
34             }
35             sub set_fake_time {
36 8     8 0 109 my ( $self, $time ) = @_;
37 8         18 $Mail::DMARC::Base::_fake_time = $time;
38 8         20 return;
39             }
40              
41             sub config {
42 2196     2196 0 42023 my ( $self, $file, @too_many ) = @_;
43 2196 50       4038 croak "invalid args" if scalar @too_many;
44 2196 100 66     12037 return $self->{config} if ref $self->{config} && !$file;
45 47         304 return $self->{config} = $self->get_config($file);
46             }
47              
48             sub get_prefix {
49 25     25 0 468 my ($self, $subdir) = @_;
50 25 100       80 return map { $_ . ($subdir ? $subdir : '') } qw[ /usr/local/ /opt/local/ / ./ ];
  100         345  
51             }
52              
53             sub get_sharefile {
54 22     22 0 645 my ($self, $file) = @_;
55              
56 22         154 my $match = File::ShareDir::dist_file( 'Mail-DMARC', $file );
57 22 50       4527 print "using $match for $file\n" if $self->verbose;
58 22         181 return $match;
59             }
60              
61             sub get_config {
62 47     47 0 117 my $self = shift;
63 47 50 66     390 my $file = shift || $ENV{MAIL_DMARC_CONFIG_FILE} || $self->{config_file} or croak;
64 47 100       1419 return Config::Tiny->read($file) if -r $file; # fully qualified
65 12         106 foreach my $d ($self->get_prefix('etc')) {
66 48 100       685 next if !-d $d;
67 24 50       338 next if !-e "$d/$file";
68 0 0       0 croak "unreadable file: $d/$file" if !-r "$d/$file";
69 0         0 my $Config = Config::Tiny->new;
70 0         0 return Config::Tiny->read("$d/$file");
71             }
72              
73 12 100       167 if ($file ne 'mail-dmarc.ini') {
74 1         252 croak "unable to find requested config file $file\n";
75             }
76 11         81 return Config::Tiny->read( $self->get_sharefile('mail-dmarc.ini') );
77             }
78              
79             sub any_inet_ntop {
80 24     24 0 3915 my ( $self, $ip_bin ) = @_;
81 24 50       67 $ip_bin or croak "missing IP in request";
82              
83 24 100       81 if ( length $ip_bin == 16 ) {
84 6         61 return Socket6::inet_ntop( AF_INET6, $ip_bin );
85             }
86              
87 18         235 return Socket6::inet_ntop( AF_INET, $ip_bin );
88             }
89              
90             sub any_inet_pton {
91 14     14 0 4012 my ( $self, $ip_txt ) = @_;
92 14 50       51 $ip_txt or croak "missing IP in request";
93              
94 14 100       89 if ( $ip_txt =~ /:/ ) {
95 4   33     35 return Socket6::inet_pton( AF_INET6, $ip_txt )
96             || croak "invalid IPv6: $ip_txt";
97             }
98              
99 10   33     112 return Socket6::inet_pton( AF_INET, $ip_txt )
100             || croak "invalid IPv4: $ip_txt";
101             }
102              
103             {
104             my $public_suffixes;
105             my $public_suffixes_stamp;
106              
107             sub get_public_suffix_list {
108 341     341 0 554 my ( $self ) = @_;
109 341 100       699 if ( $public_suffixes ) { return $public_suffixes; }
  334         585  
110 16     16   160 no warnings 'once'; ## no critic
  16         45  
  16         30247  
111 7         14 $Mail::DMARC::psl_loads++;
112 7         64 my $file = $self->find_psl_file();
113 7         95 $public_suffixes_stamp = ( stat( $file ) )[9];
114              
115 6 50   6   51 open my $fh, '<:encoding(UTF-8)', $file
  6         11  
  6         86  
  7         508  
116             or croak "unable to open $file for read: $!\n";
117             # load PSL into hash for fast lookups, esp. for long running daemons
118 78078         152745 my %psl = map { $_ => 1 }
119 96663         137481 grep { $_ !~ /^[\/\s]/ } # weed out comments & whitespace
120 7         8880 map { chomp($_); $_ } ## no critic, remove line endings
  96663         153310  
  96663         114565  
121             <$fh>;
122 7         12587 close $fh;
123 7         112 return $public_suffixes = \%psl;
124             }
125              
126             sub check_public_suffix_list {
127 2     2 0 2252 my ( $self ) = @_;
128 2         8 my $file = $self->find_psl_file();
129 2         28 my $new_public_suffixes_stamp = ( stat( $file ) )[9];
130 2 100       11 if ( $new_public_suffixes_stamp != $public_suffixes_stamp ) {
131 1         2547 $public_suffixes = undef;
132 1         11 $self->get_public_suffix_list();
133 1         9 return 1;
134             }
135 1         27 return 0;
136             }
137             }
138              
139             sub is_public_suffix {
140 340     340 1 10455 my ( $self, $zone ) = @_;
141              
142 340 50       678 croak "missing zone name!" if !$zone;
143              
144 340         689 my $public_suffixes = $self->get_public_suffix_list();
145              
146 340 100       1013 $zone = domain_to_unicode( $zone ) if $zone =~ /xn--/;
147              
148 340 100       44049 return 1 if $public_suffixes->{$zone};
149              
150 164         483 my @labels = split /\./, $zone;
151 164         554 $zone = join '.', '*', (@labels)[ 1 .. scalar(@labels) - 1 ];
152              
153 164 100       486 return 1 if $public_suffixes->{$zone};
154 163         606 return 0;
155             }
156              
157             sub update_psl_file {
158 0     0 1 0 my ($self, $dryrun) = @_;
159              
160 0         0 my $psl_file = $self->find_psl_file();
161              
162 0 0       0 die "No Public Suffix List file found\n" if ( ! $psl_file );
163 0 0       0 die "Public suffix list file $psl_file not found\n" if ( ! -f $psl_file );
164 0 0       0 die "Cannot write to Public Suffix List file $psl_file\n" if ( ! -w $psl_file );
165              
166 0         0 my $url = 'https://publicsuffix.org/list/effective_tld_names.dat';
167 0 0       0 if ( $dryrun ) {
168 0         0 print "Will attempt to update the Public Suffix List file at $psl_file (dryrun mode)\n";
169 0         0 return;
170             }
171              
172 0         0 my $response = HTTP::Tiny->new->mirror( $url, $psl_file );
173 0         0 my $content = $response->{'content'};
174 0 0       0 if ( !$response->{'success'} ) {
175 0         0 my $status = $response->{'status'};
176 0         0 die "HTTP Request for Public Suffix List file failed with error $status ($content)\n";
177             }
178             else {
179 0 0       0 if ( $response->{'status'} eq '304' ) {
180 0         0 print "Public Suffix List file $psl_file not modified\n";
181             }
182             else {
183 0         0 print "Public Suffix List file $psl_file updated\n";
184             }
185             }
186 0         0 return;
187             }
188              
189             sub find_psl_file {
190 10     10 0 1623 my ($self) = @_;
191              
192 10   50     50 my $file = $self->config->{dns}{public_suffix_list} || 'share/public_suffix_list';
193 10 0 33     4107 if ( $file =~ /^\// && -f $file && -r $file ) {
      33        
194 0 0       0 print "using $file for Public Suffix List\n" if $self->verbose;
195 0         0 return $file;
196             }
197 10         17 my $path;
198 10         51 foreach $path ($self->get_prefix('share/' . $file)) { ## no critic
199 40 50 33     515 last if ( -f $path && -r $path );
200             }
201 10 50 33     55 if ($path && -r $path) {
202 0 0       0 print "using $path for Public Suffix List\n" if $self->verbose;
203 0         0 return $path;
204             };
205              
206             # Fallback to included suffic list
207 10         42 return $self->get_sharefile('public_suffix_list');
208             }
209              
210             sub has_dns_rr {
211 73     73 1 3029 my ( $self, $type, $domain ) = @_;
212              
213 73         130 my @matches;
214 73         233 my $res = $self->get_resolver();
215 73 100       351 my $query = $res->query( $domain, $type ) or do {
216 40 50       1070279 return 0 if ! wantarray;
217 0         0 return @matches;
218             };
219 33         1226509 for my $rr ( $query->answer ) {
220 61 100       3343 next if $rr->type ne $type;
221 57 50       893 push @matches, $rr->type eq 'A' ? $rr->address
    100          
    50          
    50          
    100          
    50          
    100          
222             : $rr->type eq 'PTR' ? $rr->ptrdname
223             : $rr->type eq 'NS' ? $rr->nsdname
224             : $rr->type eq 'TXT' ? $rr->txtdata
225             : $rr->type eq 'SPF' ? $rr->txtdata
226             : $rr->type eq 'AAAA' ? $rr->address
227             : $rr->type eq 'MX' ? $rr->exchange
228             : $rr->answer;
229             }
230 33 50       3607 return scalar @matches if ! wantarray;
231 0         0 return @matches;
232             }
233              
234             sub epoch_to_iso {
235 37     37 0 377 my ($self, $epoch) = @_;
236              
237 37         812 my @fields = localtime( $epoch );
238              
239 37         151 my $ss = sprintf( "%02i", $fields[0] ); # seconds
240 37         66 my $mn = sprintf( "%02i", $fields[1] ); # minutes
241 37         69 my $hh = sprintf( "%02i", $fields[2] ); # hours (24 hour clock)
242              
243 37         57 my $dd = sprintf( "%02i", $fields[3] ); # day of month
244 37         65 my $mm = sprintf( "%02i", $fields[4] + 1 ); # month
245 37         67 my $yy = ( $fields[5] + 1900 ); # year
246              
247 37         268 return "$yy-$mm-$dd" .'T'."$hh:$mn:$ss";
248             }
249              
250             sub get_resolver {
251 97     97 1 1706 my $self = shift;
252 97   50     507 my $timeout = shift || $self->config->{dns}{timeout} || 5;
253 97 100       4098 return $self->{resolver} if defined $self->{resolver};
254 11         183 $self->{resolver} = Net::DNS::Resolver->new( dnsrch => 0 );
255 11         6226 $self->{resolver}->tcp_timeout($timeout);
256 11         280 $self->{resolver}->udp_timeout($timeout);
257 11         233 return $self->{resolver};
258             }
259              
260             sub set_resolver {
261 0     0 1 0 my ($self,$resolver) = @_;
262 0         0 $self->{resolver} = $resolver;
263 0         0 return;
264             }
265              
266             sub is_valid_ip {
267 21     21 1 9405 my ( $self, $ip ) = @_;
268              
269             # Using Regexp::Common removes perl 5.8 compat
270             # Perl 5.008009 does not support the pattern $RE{net}{IPv6}.
271             # You need Perl 5.01 or later
272              
273 21 100       81 if ( $ip =~ /:/ ) {
274 1         7 return Net::IP->new( $ip, 6 );
275             }
276              
277 20         145 return Net::IP->new( $ip, 4 );
278             }
279              
280             sub is_valid_domain {
281 68     68 1 2573 my ( $self, $domain ) = @_;
282 68 100       538 return 0 if $domain !~ /^$RE{net}{domain}{-rfc1101}{-nospace}$/x;
283 65         20660 my $tld = ( split /\./, lc $domain )[-1];
284 65 100       252 return 1 if $self->is_public_suffix($tld);
285 5         19 $tld = join( '.', ( split /\./, $domain )[ -2, -1 ] );
286 5 50       12 return 1 if $self->is_public_suffix($tld);
287 5         343 return 0;
288             }
289              
290             sub is_valid_spf_scope {
291 53     53 0 133 my ($self, $scope ) = @_;
292 53 50       92 return lc $scope if grep { lc $scope eq $_ } qw/ mfrom helo /;
  106         423  
293 0         0 carp "$scope is not a valid SPF scope";
294 0         0 return;
295             }
296              
297             sub is_valid_spf_result {
298 53     53 0 114 my ($self, $result ) = @_;
299 53 50       103 return 1 if grep { lc $result eq $_ }
  371         761  
300             qw/ fail neutral none pass permerror softfail temperror /;
301 0         0 carp "$result is not a valid SPF result";
302 0         0 return;
303             }
304              
305             sub slurp {
306 11     11 0 35 my ( $self, $file ) = @_;
307 11 50       567 open my $FH, '<', $file or croak "unable to read $file: $!";
308 11         45 my $contents = do { local $/; <$FH> }; ## no critic (Local)
  11         63  
  11         497  
309 11         159 close $FH;
310 11         121 return $contents;
311             }
312              
313             sub verbose {
314 232 100   232 0 1141 return $_[0]->{verbose} if 1 == scalar @_;
315 4         15 return $_[0]->{verbose} = $_[1];
316             }
317              
318             1;
319              
320             __END__