svn commit: r1933226 - in spamassassin/trunk: lib/Mail/SpamAssassin lib/Mail/SpamAssassin/Conf lib/Mail/SpamAssassin/Plugin t

[email protected] Tue, 21 Apr 2026 21:02:57 -0000
Newsgroups gmane.mail.spam.spamassassin.cvs
Message-ID <177680537760.1294686.8796666470046448897@svn03-he-fi>
Author: gbechis
Date: Tue Apr 21 21:02:57 2026
New Revision: 1933226

Log:
Substitute %{TAGNAME} at each message check, fixes capture tags in spamd(8)
bz #8388

Added:
   spamassassin/trunk/t/regexp_named_capture_spamd.t
Modified:
   spamassassin/trunk/lib/Mail/SpamAssassin/Conf.pm
   spamassassin/trunk/lib/Mail/SpamAssassin/Conf/Parser.pm
   spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/Check.pm

Modified: spamassassin/trunk/lib/Mail/SpamAssassin/Conf.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/Conf.pm	Tue Apr 21 20:13:12 2026	(r1933225)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Conf.pm	Tue Apr 21 21:02:57 2026	(r1933226)
@@ -5129,6 +5129,7 @@ sub new {
   # regex capture template rules
   $self->{capture_rules} = {};
   $self->{capture_template_rules} = {};
+  $self->{capture_template_strings} = {};
 
   # testing stuff
   $self->{regression_tests} = { };

Modified: spamassassin/trunk/lib/Mail/SpamAssassin/Conf/Parser.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/Conf/Parser.pm	Tue Apr 21 20:13:12 2026	(r1933225)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Conf/Parser.pm	Tue Apr 21 21:02:57 2026	(r1933226)
@@ -1526,11 +1526,15 @@ sub parse_captures {
   # Check for named regex capture templates
   if (index($$re, '%{') >= 0) {
     local($1);
+    my $template = $$re;  # save original before %{FOO} -> %\{FOO\} escaping
     # Replace %{FOO} with %\{FOO\} so compile_regexp doesn't fail with unescaped left brace
     while ($$re =~ s/(?<!\\)\%\{([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*(?:\([^\)\}]*\))?)\}/%\\{$1\\}/g) {
       dbg("config: found named capture for rule $name: $1");
       $self->{conf}->{capture_template_rules}->{$name}->{$1} = 1;
     }
+    if (exists $self->{conf}->{capture_template_rules}->{$name}) {
+      $self->{conf}->{capture_template_strings}->{$name} = $template;
+    }
   }
   # Make rules with captures run before anything else
   if ($$re =~ /\(\?P?[<'][A-Z]/) {

Modified: spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/Check.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/Check.pm	Tue Apr 21 20:13:12 2026	(r1933225)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Plugin/Check.pm	Tue Apr 21 21:02:57 2026	(r1933226)
@@ -22,7 +22,7 @@ use Time::HiRes qw(time);
 
 use Mail::SpamAssassin::Plugin;
 use Mail::SpamAssassin::Logger;
-use Mail::SpamAssassin::Util qw(untaint_var);
+use Mail::SpamAssassin::Util qw(untaint_var compile_regexp);
 use Mail::SpamAssassin::Timeout;
 use Mail::SpamAssassin::Constants qw(:sa);
 
@@ -699,7 +699,7 @@ sub do_head_tests {
                 $whlast = 'last if ++$hits >= '.untaint_var($1).';';
               }
             }
-            $expr = '$hval '.$op.' /$test_qr/'.$matchg.'op';
+            $expr = '$hval '.$op.' /$test_qr/'.$matchg.'p';
           }
 
           # Make sure rule is marked ready for meta rules
@@ -774,7 +774,7 @@ sub do_body_tests {
       $sub .= '
         pos $l = 0;
         '.$self->hash_line_for_rule($pms, $rulename).'
-        while ($l =~ /$test_qr/gop) {
+        while ($l =~ /$test_qr/gp) {
           '.$self->capture_plugin_code().'
           $self->got_hit(q{'.$rulename.'}, "BODY: ", ruletype => "body");
           '. $self->hit_rule_plugin_code($pms, $rulename, "body", "") . '
@@ -796,7 +796,7 @@ sub do_body_tests {
       }
       $sub .= '
         '.$self->hash_line_for_rule($pms, $rulename).'
-        if ($l =~ /$test_qr/op) {
+        if ($l =~ /$test_qr/p) {
           '.$self->capture_plugin_code().'
           $self->got_hit(q{'.$rulename.'}, "BODY: ", ruletype => "body");
           '. $self->hit_rule_plugin_code($pms, $rulename, "body", "last") .'
@@ -852,7 +852,7 @@ sub do_uri_tests {
       uri_'.$loopid.': foreach my $l (@_) {
         pos $l = 0;
         '.$self->hash_line_for_rule($pms, $rulename).'
-        while ($l =~ /$test_qr/gop) {
+        while ($l =~ /$test_qr/gp) {
            '.$self->capture_plugin_code().'
            $self->got_hit(q{'.$rulename.'}, "URI: ", ruletype => "uri");
            '. $self->hit_rule_plugin_code($pms, $rulename, "uri", "") . '
@@ -864,7 +864,7 @@ sub do_uri_tests {
       $sub .= '
       foreach my $l (@_) {
         '.$self->hash_line_for_rule($pms, $rulename).'
-          if ($l =~ /$test_qr/op) {
+          if ($l =~ /$test_qr/p) {
            '.$self->capture_plugin_code().'
            $self->got_hit(q{'.$rulename.'}, "URI: ", ruletype => "uri");
            '. $self->hit_rule_plugin_code($pms, $rulename, "uri", "last") .'
@@ -918,7 +918,7 @@ sub do_rawbody_tests {
       rawbody_'.$loopid.': foreach my $l (@_) {
         pos $l = 0;
         '.$self->hash_line_for_rule($pms, $rulename).'
-        while ($l =~ /$test_qr/gop) {
+        while ($l =~ /$test_qr/gp) {
            '.$self->capture_plugin_code().'
            $self->got_hit(q{'.$rulename.'}, "RAW: ", ruletype => "rawbody");
            '. $self->hit_rule_plugin_code($pms, $rulename, "rawbody", "") . '
@@ -931,7 +931,7 @@ sub do_rawbody_tests {
       $sub .= '
       foreach my $l (@_) {
         '.$self->hash_line_for_rule($pms, $rulename).'
-        if ($l =~ /$test_qr/op) {
+        if ($l =~ /$test_qr/p) {
            '.$self->capture_plugin_code().'
            $self->got_hit(q{'.$rulename.'}, "RAW: ", ruletype => "rawbody");
            '. $self->hit_rule_plugin_code($pms, $rulename, "rawbody", "last") . '
@@ -1376,37 +1376,42 @@ sub capture_rules_replace {
 
   return '{' unless exists $conf->{capture_template_rules}->{$rulename};
 
-  # Replace all named capture templates in regex, format %{CAPTURE_NAME}
-  # Note that backquotes must be double escaped in $test_qr
+  # Per-message: substitute %{CAPTURE_NAME} placeholders in the original
+  # regex template string (with unescaped braces), then compile it.
   my $code = '
-      foreach my $cname (keys %{$self->{conf}->{capture_template_rules}->{q{'.$rulename.'}}}) {
-        my $valref = $self->get_tag_raw($cname);
-        my @vals = grep { defined $_ && $_ ne "" } (ref $valref ? @$valref : $valref);
-        if (@vals) {
-          my $cval = "(?:".join("|", map { quotemeta($_) } @vals).")";
-          $test_qr =~ s/(?<!\\\\)\\%\\\\\\{\Q${cname}\E\\\\\\}/$cval/gs;
+      {
+        my $tmpl = $self->{conf}->{capture_template_strings}->{q{'.$rulename.'}};
+        foreach my $cname (keys %{$self->{conf}->{capture_template_rules}->{q{'.$rulename.'}}}) {
+          my $valref = $self->get_tag_raw($cname);
+          my @vals = grep { defined $_ && $_ ne "" } (ref $valref ? @$valref : $valref);
+          if (@vals) {
+            my $cval = "(?:".join("|", map { quotemeta($_) } @vals).")";
+            $tmpl =~ s/(?<!\\\\)%\\{\\Q$cname\\E\\}/$cval/g;
   ';
   if ($would_log_rules_all) {
     $code .= '
-          dbg("rules-all: replaced regex capture template: %s, %s, %s",
-            q{'.$rulename.'}, $cname, $test_qr);
+            dbg("rules-all: replaced regex capture template: %s, %s, %s",
+            q{'.$rulename.'}, $cname, $tmpl);
     ';
   }
   # bz 8360, instead of disabling the entire rule, change the part of the regexp that doesn't match to
   # a different regexp that will never match (without capture tags)
   $code .= '
-        } else {
-          my $cval = "(?!)";
-          $test_qr =~ s/(?<!\\\\)\\%\\\\\\{\Q${cname}\E\\\\\\}/$cval/gs;
+          } else {
+            my $cval = "(?!)";
+            $tmpl =~ s/(?<!\\\\)%\\{\\Q$cname\\E\\}/$cval/g;
   ';
   if ($would_log_rules_all) {
     $code .= '
-          dbg("rules-all: dependent tag not defined on rule %s, using empty alternation: %s",
-            q{'.$rulename.'}, $cname);
+            dbg("rules-all: dependent tag not defined on rule %s, using empty alternation: %s",
+              q{'.$rulename.'}, $cname);
     ';
   }
   $code .= '
+          }
         }
+        my ($compiled_re) = Mail::SpamAssassin::Util::compile_regexp($tmpl, 1);
+        $test_qr = $compiled_re;
       }
       if ($test_qr) {
   ';

Added: spamassassin/trunk/t/regexp_named_capture_spamd.t
==============================================================================
--- /dev/null	00:00:00 1970	(empty, because file is newly added)
+++ spamassassin/trunk/t/regexp_named_capture_spamd.t	Tue Apr 21 21:02:57 2026	(r1933226)
@@ -0,0 +1,37 @@
+#!/usr/bin/perl -T
+
+use lib '.'; use lib 't';
+use SATest; sa_t_init("regexp_named_capture_spamd");
+
+use Test::More;
+plan skip_all => "Spamd tests disabled" if $SKIP_SPAMD_TESTS;
+plan tests => 3;
+
+# bz #8388
+# Regression test for the CAPTURING TAGS feature in spamd mode.
+#
+
+tstlocalrules(q{
+  score TEST_SPAMD_CAP_CAPTURE  1.0
+  score TEST_SPAMD_CAP_TEMPLATE 1.0
+  score TEST_SPAMD_CAP_UNDEF    1.0
+
+  # Sets tag TESTCAP_SPAMD; runs at priority -10000
+  body TEST_SPAMD_CAP_CAPTURE /release of (?<TESTCAP_SPAMD>\w+)/
+
+  # Uses %{TESTCAP_SPAMD}: must match after warm-up via spamd
+  body TEST_SPAMD_CAP_TEMPLATE m,www\.%{TESTCAP_SPAMD}\.,i
+
+  # Undefined tag in alternation: the (?!) branch never matches but
+  # the plain-text alternative 'Evolution' still should
+  body TEST_SPAMD_CAP_UNDEF /(?:%{TESTCAP_UNDEF_X}|Evolution)/
+});
+
+%patterns = (
+  q{TEST_SPAMD_CAP_CAPTURE},  'capture_producing_rule',
+  q{TEST_SPAMD_CAP_TEMPLATE}, 'capture_template_rule',
+  q{TEST_SPAMD_CAP_UNDEF},    'capture_undef_alternation',
+);
+
+sdrun("-L", "< data/nice/001", \&patterns_run_cb);
+ok_all_patterns();