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.20230215';
3 16     16   7626 use strict;
  16         40  
  16         453  
4 16     16   78 use warnings;
  16         63  
  16         404  
5 16     16   268 use 5.10.0;
  16         53  
6              
7 16     16   114 use Carp;
  16         34  
  16         972  
8 16     16   8059 use Config::Tiny;
  16         18032  
  16         548  
9 16     16   4490 use File::ShareDir;
  16         232124  
  16         804  
10 16     16   11729 use HTTP::Tiny;
  16         600039  
  16         670  
11 16     16   5868 use IO::File;
  16         16787  
  16         2162  
12 16     16   8588 use Net::DNS::Resolver;
  16         918921  
  16         2781  
13 16     16   15621 use Net::IDN::Encode qw/domain_to_unicode/;
  16         1932352  
  16         1454  
14 16     16   12079 use Net::IP;
  16         945015  
  16         3156  
15 16     16   10744 use Regexp::Common 2013031301 qw /net/;
  16         44684  
  16         104  
16 16     16   48937 use Socket;
  16         70  
  16         10269  
17 16     16   8055 use Socket6 qw//; # don't export symbols
  16         20130  
  16         13705  
18              
19             sub new {
20 99     99 0 14149 my ( $class, @args ) = @_;
21 99 50       364 croak "invalid args" if scalar @args % 2 != 0;
22 99         967 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 108 my ( $self ) = @_;
32 33 100       125 my $time = defined $Mail::DMARC::Base::_fake_time ? $Mail::DMARC::Base::_fake_time : time;
33 33         615 return $time;
34             }
35             sub set_fake_time {
36 8     8 0 92 my ( $self, $time ) = @_;
37 8         27 $Mail::DMARC::Base::_fake_time = $time;
38 8         21 return;
39             }
40              
41             sub config {
42 2196     2196 0 41175 my ( $self, $file, @too_many ) = @_;
43 2196 50       4611 croak "invalid args" if scalar @too_many;
44 2196 100 66     13494 return $self->{config} if ref $self->{config} && !$file;
45 47         280 return $self->{config} = $self->get_config($file);
46             }
47              
48             sub get_prefix {
49 25     25 0 461 my ($self, $subdir) = @_;
50 25 100       74 return map { $_ . ($subdir ? $subdir : '') } qw[ /usr/local/ /opt/local/ / ./ ];
  100         415  
51             }
52              
53             sub get_sharefile {
54 22     22 0 678 my ($self, $file) = @_;
55              
56 22         151 my $match = File::ShareDir::dist_file( 'Mail-DMARC', $file );
57 22 50       4874 print "using $match for $file\n" if $self->verbose;
58 22         229 return $match;
59             }
60              
61             sub get_config {
62 47     47 0 88 my $self = shift;
63 47 50 66     371 my $file = shift || $ENV{MAIL_DMARC_CONFIG_FILE} || $self->{config_file} or croak;
64 47 100       1325 return Config::Tiny->read($file) if -r $file; # fully qualified
65 12         105 foreach my $d ($self->get_prefix('etc')) {
66 48 100       604 next if !-d $d;
67 24 50       328 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         271 croak "unable to find requested config file $file\n";
75             }
76 11         84 return Config::Tiny->read( $self->get_sharefile('mail-dmarc.ini') );
77             }
78              
79             sub any_inet_ntop {
80 24     24 0 4817 my ( $self, $ip_bin ) = @_;
81 24 50       68 $ip_bin or croak "missing IP in request";
82              
83 24 100       70 if ( length $ip_bin == 16 ) {
84 6         65 return Socket6::inet_ntop( AF_INET6, $ip_bin );
85             }
86              
87 18         154 return Socket6::inet_ntop( AF_INET, $ip_bin );
88             }
89              
90             sub any_inet_pton {
91 14     14 0 4123 my ( $self, $ip_txt ) = @_;
92 14 50       56 $ip_txt or croak "missing IP in request";
93              
94 14 100       67 if ( $ip_txt =~ /:/ ) {
95 4   33     40 return Socket6::inet_pton( AF_INET6, $ip_txt )
96             || croak "invalid IPv6: $ip_txt";
97             }
98              
99 10   33     148 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 489 my ( $self ) = @_;
109 341 100       677 if ( $public_suffixes ) { return $public_suffixes; }
  334         564  
110 16     16   159 no warnings 'once'; ## no critic
  16         54  
  16         30418  
111 7         14 $Mail::DMARC::psl_loads++;
112 7         39 my $file = $self->find_psl_file();
113 7         105 $public_suffixes_stamp = ( stat( $file ) )[9];
114              
115 6 50   6   47 open my $fh, '<:encoding(UTF-8)', $file
  6         21  
  6         102  
  7         470  
116             or croak "unable to open $file for read: $!\n";
117             # load PSL into hash for fast lookups, esp. for long running daemons
118 80458         157698 my %psl = map { $_ => 1 }
119 99666         143750 grep { $_ !~ /^[\/\s]/ } # weed out comments & whitespace
120 7         8972 map { chomp($_); $_ } ## no critic, remove line endings
  99666         161632  
  99666         116952  
121             <$fh>;
122 7         12207 close $fh;
123 7         90 return $public_suffixes = \%psl;
124             }
125              
126             sub check_public_suffix_list {
127 2     2 0 2434 my ( $self ) = @_;
128 2         8 my $file = $self->find_psl_file();
129 2         30 my $new_public_suffixes_stamp = ( stat( $file ) )[9];
130 2 100       13 if ( $new_public_suffixes_stamp != $public_suffixes_stamp ) {
131 1         2733 $public_suffixes = undef;
132 1         8 $self->get_public_suffix_list();
133 1         7 return 1;
134             }
135 1         31 return 0;
136             }
137             }
138              
139             sub is_public_suffix {
140 340     340 1 10466 my ( $self, $zone ) = @_;
141              
142 340 50       604 croak "missing zone name!" if !$zone;
143              
144 340         639 my $public_suffixes = $self->get_public_suffix_list();
145              
146 340 100       847 $zone = domain_to_unicode( $zone ) if $zone =~ /xn--/;
147              
148 340 100       42303 return 1 if $public_suffixes->{$zone};
149              
150 164         457 my @labels = split /\./, $zone;
151 164         509 $zone = join '.', '*', (@labels)[ 1 .. scalar(@labels) - 1 ];
152              
153 164 100       417 return 1 if $public_suffixes->{$zone};
154 163         581 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 1690 my ($self) = @_;
191              
192 10   50     57 my $file = $self->config->{dns}{public_suffix_list} || 'share/public_suffix_list';
193 10 0 33     4020 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         20 my $path;
198 10         57 foreach $path ($self->get_prefix('share/' . $file)) { ## no critic
199 40 50 33     559 last if ( -f $path && -r $path );
200             }
201 10 50 33     65 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         48 return $self->get_sharefile('public_suffix_list');
208             }
209              
210             sub has_dns_rr {
211 73     73 1 4243 my ( $self, $type, $domain ) = @_;
212              
213 73         115 my @matches;
214 73         222 my $res = $self->get_resolver();
215 73 100       321 my $query = $res->query( $domain, $type ) or do {
216 40 50       2069732 return 0 if ! wantarray;
217 0         0 return @matches;
218             };
219 33         687771 for my $rr ( $query->answer ) {
220 61 100       3730 next if $rr->type ne $type;
221 57 50       955 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       3606 return scalar @matches if ! wantarray;
231 0         0 return @matches;
232             }
233              
234             sub epoch_to_iso {
235 37     37 0 406 my ($self, $epoch) = @_;
236              
237 37         1036 my @fields = localtime( $epoch );
238              
239 37         195 my $ss = sprintf( "%02i", $fields[0] ); # seconds
240 37         81 my $mn = sprintf( "%02i", $fields[1] ); # minutes
241 37         87 my $hh = sprintf( "%02i", $fields[2] ); # hours (24 hour clock)
242              
243 37         64 my $dd = sprintf( "%02i", $fields[3] ); # day of month
244 37         87 my $mm = sprintf( "%02i", $fields[4] + 1 ); # month
245 37         70 my $yy = ( $fields[5] + 1900 ); # year
246              
247 37         362 return "$yy-$mm-$dd" .'T'."$hh:$mn:$ss";
248             }
249              
250             sub get_resolver {
251 97     97 1 1712 my $self = shift;
252 97   50     449 my $timeout = shift || $self->config->{dns}{timeout} || 5;
253 97 100       4211 return $self->{resolver} if defined $self->{resolver};
254 11         191 $self->{resolver} = Net::DNS::Resolver->new( dnsrch => 0 );
255 11         5814 $self->{resolver}->tcp_timeout($timeout);
256 11         281 $self->{resolver}->udp_timeout($timeout);
257 11         239 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 13753 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       96 if ( $ip =~ /:/ ) {
274 1         7 return Net::IP->new( $ip, 6 );
275             }
276              
277 20         174 return Net::IP->new( $ip, 4 );
278             }
279              
280             sub is_valid_domain {
281 68     68 1 2699 my ( $self, $domain ) = @_;
282 68 100       591 return 0 if $domain !~ /^$RE{net}{domain}{-rfc1101}{-nospace}$/x;
283 65         20287 my $tld = ( split /\./, lc $domain )[-1];
284 65 100       664 return 1 if $self->is_public_suffix($tld);
285 5         30 $tld = join( '.', ( split /\./, $domain )[ -2, -1 ] );
286 5 50       15 return 1 if $self->is_public_suffix($tld);
287 5         367 return 0;
288             }
289              
290             sub is_valid_spf_scope {
291 53     53 0 115 my ($self, $scope ) = @_;
292 53 50       90 return lc $scope if grep { lc $scope eq $_ } qw/ mfrom helo /;
  106         429  
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 111 my ($self, $result ) = @_;
299 53 50       107 return 1 if grep { lc $result eq $_ }
  371         717  
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 44 my ( $self, $file ) = @_;
307 11 50       524 open my $FH, '<', $file or croak "unable to read $file: $!";
308 11         37 my $contents = do { local $/; <$FH> }; ## no critic (Local)
  11         71  
  11         500  
309 11         167 close $FH;
310 11         127 return $contents;
311             }
312              
313             sub verbose {
314 232 100   232 0 1167 return $_[0]->{verbose} if 1 == scalar @_;
315 4         10 return $_[0]->{verbose} = $_[1];
316             }
317              
318             1;
319              
320             __END__