BodyRuleBaseExtractor.pm [plain text]
=head1 NAME
Mail::SpamAssassin::Plugin::BodyRuleBaseExtractor - extract "bases" from body ruleset
=head1 SYNOPSIS
This is a plugin to extract "base" strings from SpamAssassin 'body' rules,
suitable for use in Rule2XSBody rules or other parallel matching algorithms.
=cut
package Mail::SpamAssassin::Plugin::BodyRuleBaseExtractor;
use Mail::SpamAssassin::Plugin;
use Mail::SpamAssassin::Logger;
use Mail::SpamAssassin::Util::Progress;
use strict;
use warnings;
use bytes;
use vars qw(@ISA);
@ISA = qw(Mail::SpamAssassin::Plugin);
use constant DEBUG_RE_PARSING => 0;
sub new {
my $class = shift;
my $mailsaobject = shift;
$class = ref($class) || $class;
my $self = $class->SUPER::new($mailsaobject);
bless ($self, $class);
$self->{show_progress} = 1;
return $self;
}
sub finish_parsing_end {
my ($self, $params) = @_;
my $conf = $params->{conf};
$self->extract_bases($conf);
}
sub extract_bases {
my ($self, $conf) = @_;
my $main = $conf->{main};
if (!$main->{base_extract}) { return; }
info("base extraction starting. this can take a while...");
$self->extract_set($conf, $conf->{body_tests}, 'body');
}
sub extract_set {
my ($self, $conf, $test_set, $ruletype) = @_;
foreach my $pri (keys %{$test_set}) {
my $nicepri = $pri; $nicepri =~ s/-/neg/g;
$self->extract_set_pri($conf, $test_set->{$pri}, $ruletype.'_'.$nicepri);
}
}
sub extract_set_pri {
my ($self, $conf, $rules, $ruletype) = @_;
my @good_bases = ();
my @failed = ();
my $yes = 0;
my $no = 0;
my $start = time;
$self->{main} = $conf->{main}; info ("extracting from rules of type $ruletype");
my $min_chars = 3;
my $count = 0;
my $progress;
$self->{show_progress} and $progress = Mail::SpamAssassin::Util::Progress->new({
total => scalar keys %{$rules},
itemtype => 'rules',
});
foreach my $name (keys %{$rules}) {
my $rule = $rules->{$name};
$self->{show_progress} and $progress->update(++$count);
next if ($conf->{rules_to_replace}->{$name});
my ($qr, $mods) = $self->simplify_and_qr_regexp($rule);
my @bases;
eval { @bases = $self->extract_hints($rule, $qr, $mods);
};
$@ and dbg("giving up on regexp: $@");
my $minlen;
foreach my $str (@bases) {
my $len = length $str;
if ($len < $min_chars) { $minlen = undef; @bases = (); last; }
elsif (!defined($minlen) || $len < $minlen) { $minlen = $len; }
}
if ($minlen && @bases) {
my %subsumed = ();
foreach my $base1 (@bases) {
foreach my $base2 (@bases) {
if ($base1 ne $base2 && $base1 =~ /\Q$base2\E/) {
$subsumed{$base1} = 1; }
}
}
foreach my $base (@bases) {
next if $subsumed{$base};
push @good_bases, { base => $base, orig => $rule, name => $name };
}
$yes++;
}
else {
dbg("zoom: NO $rule");
push @failed, { orig => $rule };
$no++;
}
}
$self->{show_progress} and $progress->final();
dbg ("$ruletype: found ".(scalar @good_bases).
" usable base strings in ".
"$yes rules, skipped $no rules");
$conf->{base_orig}->{$ruletype} = { };
$conf->{base_string}->{$ruletype} = { };
$count = 0;
$self->{show_progress} and $progress = Mail::SpamAssassin::Util::Progress->new({
total => scalar @good_bases,
itemtype => 'bases',
});
foreach my $set1 (@good_bases) {
$self->{show_progress} and $progress->update(++$count);
my $base1 = $set1->{base};
my $orig1 = $set1->{orig};
my $name1 = $set1->{name};
next if ($base1 eq '' or $name1 eq '');
$conf->{base_orig}->{$ruletype}->{$name1} = $orig1;
foreach my $set2 (@good_bases) {
next if ($set1 == $set2);
my $base2 = $set2->{base};
my $name2 = $set2->{name};
if ($orig1 eq $set2->{orig} &&
$base1 eq $base2 &&
$name1 eq $name2)
{
$set2->{name} = ''; $set2->{base} = '';
}
next if ($name1 =~ /\b\Q$name2\E\b/);
next if ($name2 =~ /\b\Q$name1\E\b/);
next if ($base2 eq '');
next if (length $base1 < length $base2);
next if ($base1 !~ /\Q$base2\E/);
$set1->{name} .= " ".$name2;
}
}
my %bases = ();
foreach my $set (@good_bases) {
my $base = $set->{base};
next unless $base;
if (defined $bases{$base}) {
$bases{$base} .= " ".$set->{name};
} else {
$bases{$base} = $set->{name};
}
}
foreach my $base (keys %bases) {
my @list = split (' ', $bases{$base});
my @uniqed;
{
my %u=(); @uniqed = grep {defined} map {
if (exists $u{$_}) { undef; } else { $u{$_}=undef;$_; }
} @list; undef %u;
}
my $key = join ' ', sort @uniqed;
$conf->{base_string}->{$ruletype}->{$base} = $key;
}
$self->{show_progress} and $progress->final();
my $elapsed = time - $start;
info ("$ruletype: ".
(scalar keys %{$conf->{base_string}->{$ruletype}}).
" base strings extracted in $elapsed seconds\n");
}
sub simplify_and_qr_regexp {
my $self = shift;
my $rule = shift;
my $main = $self->{main};
$rule = Mail::SpamAssassin::Util::regexp_remove_delimiters($rule);
my $mods = '';
while ($rule =~ s/^\(\?([a-z]*)\)//) { $mods .= $1; }
while ($rule =~ s/^\(\?-([a-z]*)\)//) {
foreach my $modchar (split '', $mods) {
$mods =~ s/$modchar//g;
}
}
if ($main->{bases_must_be_casei}) {
$rule = lc $rule;
$mods =~ s/i//;
$rule =~ s/(?<!\\)\(\?i\:(.*?)\)/$1/gs;
$rule =~ s/(?<!\\)\(\?-i\:(.*?)\)/$1/gs;
$rule =~ s/\(\?i\)//gs;
}
else {
die "case-i" if $rule =~ /\(\?i\)/;
die "case-i" if $mods =~ /i/;
}
$mods =~ s/m//;
$mods =~ s/s//;
$rule =~ s/\(\^\|\\b\)//gs;
$rule =~ s/\(\$\|\\b\)//gs;
$rule =~ s/\(\\b\|\^\)//gs;
$rule =~ s/\(\\b\|\$\)//gs;
$rule =~ s/\(\?\![^\)]+\)//gs;
$rule =~ s/(?<!\\)\\b//gs;
$rule =~ s/\(\?\=\[[^\]]+\]\)//gs;
($rule, $mods);
}
sub extract_hints {
my $self = shift;
my $rawrule = shift;
my $rule = shift;
my $mods = shift;
my $main = $self->{main};
my $orig = $rule;
die "anchors" if $rule =~ /^\(?(?:\^|\\A)/;
$rule =~ s/(?<!\\)(?:\$|\\Z)\)?$//;
$main->{bases_allow_noncapture_groups} or
$rule =~ s/\(\?:/\(/g;
$rule =~ s/\((.*?)\)\?/\($1\|\)/gs;
$rule =~ s/(?<!\\)(\w)\?/\($1\|\)/gs;
my ($tmpf, $tmpfh) = Mail::SpamAssassin::Util::secure_tmpfile();
my $quos = "/"; if ($rule =~ m/\Q${quos}\E/) {
$quos = "#"; if ($rule =~ m/\Q${quos}\E/) {
$quos = "'"; if ($rule =~ m/\Q${quos}\E/) {
$quos = "@"; if ($rule =~ m/\Q${quos}\E/) {
$quos = "*"; if ($rule =~ m/\Q${quos}\E/) {
$quos = "!";
}
}
}
}
}
print $tmpfh "m".$quos.$rule.$quos.$mods;
close $tmpfh or die "cannot write to $tmpf";
my $perl = $self->get_perl();
open (IN, "$perl -c -Mre=debug $tmpf 2>&1 |") or die "cannot run $perl";
my $fullstr = join('', <IN>);
close IN;
unlink $tmpf;
$fullstr =~ s/^.*\nFinal program:\n//gs;
$fullstr =~ s/^(?:.*\n|)size \d[^\n]*\n//gs;
$fullstr =~ s/^(?:.*\n|)first at \d[^\n]*\n//gs;
$fullstr =~ s/\nOffsets:.*$//gs;
$fullstr =~ s/^\S.*$//gm;
if ($fullstr !~ /((?:\s[^\n]+\n)+)/m) {
die "failed to parse Mre=debug output: $fullstr m".$quos.$rule.$quos.$mods." $rawrule";
}
my $opsstr = $1;
DEBUG_RE_PARSING and warn "Mre=debug output: $opsstr";
my @ops = ();
foreach my $op (split(/\n/s, $opsstr)) {
next unless $op;
if ($op =~ /^\s+\d+: (\s*)([A-Z]\w+)\b(.*)(?:\(\d+\))?$/) {
push @ops, [ $1, $2, $3 ];
}
elsif ($op =~ /^ (\s*)<(.*)>\.\.\.\s*$/) {
my $spcs = $1;
my $str = substr ($2, 0, 55);
push @ops, [ $spcs, '_moretrie', "<$str...>" ];
}
elsif ($op =~ /^ (\s*)(<.*>)\s*(?:\(\d+\))?$/) {
push @ops, [ $1, '_moretrie', $2 ];
}
elsif ($op =~ /^ at .+ line \d+$/) {
next; }
else {
warn "cannot parse '$op': $opsstr";
next;
}
}
my @unrolled;
if ($main->{bases_split_out_alternations}) {
@unrolled = $self->unroll_branches(0, \@ops);
} else {
@unrolled = ( \@ops );
}
my @longests = ();
foreach my $opsarray (@unrolled) {
my $longestexact = '';
my $buf = '';
my $add_candidate = sub {
if (length $buf > length $longestexact) { $longestexact = $buf; }
$buf = '';
};
my $prevop;
foreach my $op (@{$opsarray}) {
my ($spcs, $item, $args) = @{$op};
next if ($item eq 'NOTHING');
if (!$spcs && $item =~ /^EXACT/ && $args =~ /<(.*)>/)
{
$buf .= $1;
if (length $1 >= 55 && $buf =~ s/\.\.\.$//) {
$add_candidate->();
}
}
elsif (!$spcs && $item =~ /^_moretrie/ && $args =~ /<(.*)>/)
{
$buf .= $1;
if (length $1 >= 55 && $buf =~ s/\.\.\.$//) {
$add_candidate->();
}
}
elsif ($item =~ /^EXACT/ &&
$prevop && !$prevop->[0] && $prevop->[1] =~ /^CURLYX/ &&
$prevop->[2] =~ /\{(\d+),/ && $1 >= 1 &&
$args =~ /<(.*)>/)
{
$buf .= $1;
if (length $1 >= 55 && $buf =~ s/\.\.\.$//) {
$add_candidate->();
}
}
elsif ($item =~ /^_moretrie/ &&
$prevop && !$prevop->[0] && $prevop->[1] =~ /^CURLYX/ &&
$prevop->[2] =~ /\{(\d+),/ && $1 >= 1 &&
$args =~ /<(.*)>/)
{
$buf .= $1;
if (length $1 >= 60 && $buf =~ s/\.\.\.$//) {
$add_candidate->();
}
}
else {
$add_candidate->();
}
$prevop = $op;
}
$add_candidate->();
if (!$longestexact) {
die "no long-enough string found in $rawrule";
} else {
push @longests, lc $longestexact;
}
}
DEBUG_RE_PARSING and warn "longest base strings: /".join("/", @longests)."/";
return @longests;
}
sub unroll_branches {
my ($self, $depth, $opslist) = @_;
die "too deep" if ($depth++ > 5);
my @ops = (@{$opslist}); my @pre_branch_ops = ();
my $branch_spcs;
my $trie_spcs;
my $open_spcs;
DEBUG_RE_PARSING and warn "starting parse";
if (scalar @ops > 1 && $ops[0]->[1] =~ /^BRANCH/) {
my @newops = ([ "", "OPEN1", "" ]);
foreach my $op (@ops) {
push @newops, [ " ".$op->[0], $op->[1], $op->[2] ];
}
push @newops, [ "", "CLOSE1", "" ];
@ops = @newops;
}
while (1) {
my $op = shift @ops;
last unless defined $op;
my ($spcs, $item, $args) = @{$op};
DEBUG_RE_PARSING and warn "pre: [$spcs] $item $args";
if ($item =~ /^OPEN/) {
$open_spcs = $spcs;
next;
} elsif ($item =~ /^TRIE/) {
$trie_spcs = $spcs;
last;
} elsif ($item =~ /^BRANCH/) {
$branch_spcs = $spcs;
last;
} elsif ($item =~ /^EXACT/ && defined $open_spcs) {
push @pre_branch_ops, [ $open_spcs, $item, $args ];
next;
} elsif (defined $open_spcs) {
undef $open_spcs;
} else {
push @pre_branch_ops, $op;
}
}
if (scalar @ops == 0) {
return [ @pre_branch_ops ];
}
my @alts = ();
my @in_this_branch = ();
DEBUG_RE_PARSING and warn "entering branch: ".
"open='".(defined $open_spcs ? $open_spcs : 'undef')."' ".
"branch='".(defined $branch_spcs ? $branch_spcs : 'undef')."' ".
"trie='".(defined $trie_spcs ? $trie_spcs : 'undef')."'";
my $open_sub_spcs = ($branch_spcs ? $branch_spcs : "")." ";
my $trie_sub_spcs = "";
while (1) {
my $op = shift @ops;
last unless defined $op;
my ($spcs, $item, $args) = @{$op};
DEBUG_RE_PARSING and warn "in: [$spcs] $item $args";
if (defined $branch_spcs && $branch_spcs eq $spcs && $item =~ /^BRANCH/) { push @alts, [ @pre_branch_ops, @in_this_branch ];
@in_this_branch = ();
$open_sub_spcs = $branch_spcs." ";
$trie_sub_spcs = "";
next;
}
elsif (defined $branch_spcs && $branch_spcs eq $spcs && $item eq 'TAIL') { push @alts, [ @pre_branch_ops, @in_this_branch ];
undef $branch_spcs;
$open_sub_spcs = "";
$trie_sub_spcs = "";
last;
}
elsif (defined $trie_spcs && $trie_spcs eq $spcs && $item eq '_moretrie') {
if (scalar @in_this_branch > 0) {
push @alts, [ @pre_branch_ops, @in_this_branch ];
}
@in_this_branch = ( [ $open_spcs, $item, $args ] );
$open_sub_spcs = ($branch_spcs ? $branch_spcs : "")." ";
$trie_sub_spcs = " ";
next;
}
elsif (defined $open_spcs && $open_spcs eq $spcs && $item =~ /^CLOSE/) { push @alts, [ @pre_branch_ops, @in_this_branch ];
undef $branch_spcs;
undef $open_spcs;
undef $trie_spcs;
$open_sub_spcs = "";
$trie_sub_spcs = "";
last;
}
elsif ($item eq 'END') { push @alts, [ @pre_branch_ops, @in_this_branch ];
undef $branch_spcs;
undef $open_spcs;
undef $trie_spcs;
$open_sub_spcs = "";
$trie_sub_spcs = "";
last;
}
else {
if ($open_sub_spcs) {
$spcs =~ s/^$open_sub_spcs//;
$spcs =~ s/^$trie_sub_spcs//;
}
push @in_this_branch, [ $spcs, $item, $args ];
}
}
if (defined $branch_spcs) {
die "fell off end of string with a branch open: '$branch_spcs'";
}
foreach my $alt (@alts) {
push @{$alt}, @ops; }
if (DEBUG_RE_PARSING) {
print "unrolled: "; foreach my $alt (@alts) { foreach my $o (@{$alt}) { print "{/$o->[0]/$o->[1]/$o->[2]} "; } print "\n"; }
}
my @rets = ();
foreach my $alt (@alts) {
push @rets, $self->unroll_branches($depth, $alt);
}
if (DEBUG_RE_PARSING) {
print "unrolled post-recurse: "; foreach my $alt (@rets) { foreach my $o (@{$alt}) { print "{/$o->[0]/$o->[1]/$o->[2]} "; } print "\n"; }
}
return @rets;
}
sub test {
my ($self) = @_;
$self->test_split_alt("foo", "/foo/");
$self->test_split_alt("(foo)", "/foo/");
$self->test_split_alt("foo(bar)baz", "/foobarbaz/");
$self->test_split_alt("x(foo|)", "/xfoo/ /x/");
$self->test_split_alt("fo(o|)", "/foo/ /fo/");
$self->test_split_alt("(foo|bar)", "/foo/ /bar/");
$self->test_split_alt("foo|bar", "/foo/ /bar/");
$self->test_split_alt("foo (bar|baz) argh", "/foo bar argh/ /foo baz argh/");
$self->test_split_alt("foo (bar|baz|bl(arg|at)) cough", "/foo bar cough/ /foo baz cough/ /foo blarg cough/ /foo blat cough/");
$self->test_split_alt("(s(otc|tco)k)", "/sotck/ /stcok/");
$self->test_split_alt("(business partner(s|ship|)|silent partner(s|ship|))", "/business partners/ /silent partners/ /business partnership/ /silent partnership/ /business partner/ /silent partner/");
}
sub test_split_alt {
my ($self, $in, $out) = @_;
my @got = $self->split_alt($in);
$out =~ s/^\///;
$out =~ s/\/$//;
my @want = split(/\/ \//, $out);
my $failed = 0;
if (scalar @want != scalar @got) {
warn "FAIL: results count don't match";
$failed++;
}
else {
my %got = map { $_ => 1 } @got;
foreach my $w (@want) {
if (!$got{$w}) {
warn "FAIL: '$w' not found";
$failed++;
}
}
}
if ($failed) {
print "want: /".join('/ /', @want)."/\n";
print "got: /".join('/ /', @got)."/\n";
return 0;
} else {
print "ok\n";
return 1;
}
}
sub get_perl {
my ($self) = @_;
my $perl;
my $fromconf = $self->{main}->{conf}->{re_parser_perl};
if ($fromconf) {
$perl = $fromconf;
} elsif ($^X =~ m|^/|) {
$perl = $^X;
} else {
use Config;
$perl = $Config{perlpath};
$perl =~ s|/[^/]*$|/$^X|;
}
$perl =~ /^(.*)$/;
return $1;
}
1;