svn commit: r1936882 - in spamassassin/trunk: . lib/Mail/SpamAssassin/Handler lib/Mail/SpamAssassin/PDF lib/Mail/SpamAssassin/PDF/Context t t/data/pdf

[email protected]
Newsgroups gmane.mail.spam.spamassassin.cvs
Message-ID <178587577799.2375712.5291930066955975648@svn03-he-fi>
Author: fkento
Date: Tue Aug  4 20:36:17 2026
New Revision: 1936882

Log:
PDF: parse link annotations on every page, not just page 1

Annotations are now parsed for every page.  This is cheap (a dict lookup per
annotation); walking the page content streams stays page-1-only, so the
existing optimization is preserved.  That walk exists to feed ImageCount, 
ImageArea/ImageRatio and the image extraction used for OCR.

ClickArea is still accumulated only for page 1, to stay consistent with
PageArea, both of which are used to calculate ClickRatio. LinkCount and the 
URI list still cover every page.

Since links are now read from the whole document, add a pdf_max_uris setting
(default 30, 0 disables) capping the number of distinct URIs retained per
PDF, so a PDF stuffed with link annotations cannot flood the URI list and the
URIBL lookups that follow.  Duplicate links to an already-seen URI do not
consume the cap, and LinkCount still counts every link.

Added:
   spamassassin/trunk/t/data/pdf/link_page2.pdf
   spamassassin/trunk/t/data/pdf/many_uris.pdf
Modified:
   spamassassin/trunk/MANIFEST
   spamassassin/trunk/lib/Mail/SpamAssassin/Handler/PDF.pm
   spamassassin/trunk/lib/Mail/SpamAssassin/PDF/Context.pm
   spamassassin/trunk/lib/Mail/SpamAssassin/PDF/Context/Info.pm
   spamassassin/trunk/lib/Mail/SpamAssassin/PDF/Parser.pm
   spamassassin/trunk/t/pdf_info.t

Modified: spamassassin/trunk/MANIFEST
==============================================================================
--- spamassassin/trunk/MANIFEST	Tue Aug  4 18:46:58 2026	(r1936881)
+++ spamassassin/trunk/MANIFEST	Tue Aug  4 20:36:17 2026	(r1936882)
@@ -476,8 +476,10 @@ t/data/pdf/aesv3.pdf
 t/data/pdf/ascii85.pdf
 t/data/pdf/indirect_mediabox.pdf
 t/data/pdf/inline_image.pdf
+t/data/pdf/link_page2.pdf
 t/data/pdf/lzw.pdf
 t/data/pdf/mailto.pdf
+t/data/pdf/many_uris.pdf
 t/data/pdf/multipage.pdf
 t/data/pdf/ocr_image.pdf
 t/data/pdf/openaction_js.pdf

Modified: spamassassin/trunk/lib/Mail/SpamAssassin/Handler/PDF.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/Handler/PDF.pm	Tue Aug  4 18:46:58 2026	(r1936881)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/Handler/PDF.pm	Tue Aug  4 20:36:17 2026	(r1936882)
@@ -105,6 +105,14 @@ Extract at most N images per PDF, to bou
 Skip images larger than N pixels (width times height). Set to B<0> to
 disable the limit entirely and extract every image regardless of size.
 
+=item pdf_max_uris N    (default: 30)
+
+Retain at most N distinct URIs per PDF. Link annotations are read from every
+page, so a hostile PDF stuffed with links could otherwise flood the URI list
+and the URIBL lookups that follow. Additional links pointing at an
+already-seen URI do not count toward the limit, and the C<LinkCount> metric
+still counts every link. Set to B<0> to disable the limit entirely.
+
 =back
 
 =head1 EVAL RULES
@@ -256,10 +264,18 @@ use Mail::SpamAssassin::Timeout;
 use Mail::SpamAssassin::Util qw(compile_regexp untaint_var untaint_file_path
                                 proc_status_ok exit_status_str);
 use Mail::SpamAssassin::PDF::Parser;
+use Mail::SpamAssassin::PDF::Context::Info;
 use Compress::Zlib qw(compress crc32);
 
 our @ISA = qw(Mail::SpamAssassin::Handler);
 
+use constant DEFAULT_PDFTOTEXT_PATH   => '';
+use constant DEFAULT_TEXT_MAX_PAGES   => 4;
+use constant DEFAULT_EXTRACT_IMAGES   => 1;
+use constant DEFAULT_MAX_IMAGES       => 4;
+use constant DEFAULT_MAX_IMAGE_PIXELS => 25_000_000;
+use constant DEFAULT_MAX_URIS         => 30;
+
 sub log_dbg  { Mail::SpamAssassin::Logger::dbg ("pdfinfo2: @_"); }
 sub log_warn { Mail::SpamAssassin::Logger::log_message('warn', "pdfinfo2: @_"); }
 
@@ -315,7 +331,7 @@ sub set_config {
       # extraction is disabled (pdf2_word_count and pdftext rules then no-op).
       setting => 'pdf_pdftotext_path',
       is_admin => 1,
-      default => '',
+      default => DEFAULT_PDFTOTEXT_PATH,
       type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING,
     },
     {
@@ -323,7 +339,7 @@ sub set_config {
       # Bounds the work done on large documents and resists content stuffing.
       setting => 'pdf_text_max_pages',
       is_admin => 1,
-      default => 4,
+      default => DEFAULT_TEXT_MAX_PAGES,
       type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
     },
     {
@@ -332,14 +348,25 @@ sub set_config {
       # default; set to 0 to disable.
       setting => 'pdf_extract_images',
       is_admin => 1,
-      default => 1,
+      default => DEFAULT_EXTRACT_IMAGES,
       type => $Mail::SpamAssassin::Conf::CONF_TYPE_BOOL,
     },
     {
       # Extract at most this many images per PDF (bounds OCR work downstream).
       setting => 'pdf_max_images',
       is_admin => 1,
-      default => 4,
+      default => DEFAULT_MAX_IMAGES,
+      type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
+    },
+    {
+      # Retain at most this many *distinct* URIs per PDF.  Link annotations are
+      # parsed on every page, so a hostile PDF stuffed with links could otherwise
+      # flood the URI list and the URIBL lookups that follow.  Duplicate links to
+      # an already-seen URI do not count toward the cap, and LinkCount still
+      # counts every link.  Set to 0 to disable the limit entirely.
+      setting => 'pdf_max_uris',
+      is_admin => 1,
+      default => DEFAULT_MAX_URIS,
       type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
     },
     {
@@ -350,7 +377,7 @@ sub set_config {
       # every image regardless of size).
       setting => 'pdf_max_image_pixels',
       is_admin => 1,
-      default => 25_000_000,
+      default => DEFAULT_MAX_IMAGE_PIXELS,
       type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC,
     },
     {
@@ -427,7 +454,7 @@ sub _extract_text {
   return undef unless defined $bin;
 
   my $pages = $conf->{pdf_text_max_pages};
-  $pages = 4 unless defined $pages;
+  $pages = DEFAULT_TEXT_MAX_PAGES unless defined $pages;
   my $secs = $conf->{handler_time_limit} || 10;
 
   my ($tmp_file, $err_file, $pid, $resp, $errno);
@@ -627,7 +654,11 @@ sub handle_pdf {
 
   # Parse PDF.  The handler framework already bounds this call with a timeout,
   # so the parser does not need its own.
-  my $pdf = Mail::SpamAssassin::PDF::Parser->new();
+  my $context = Mail::SpamAssassin::PDF::Context::Info->new(
+    max_uris => defined($pms->{conf}->{pdf_max_uris})
+      ? $pms->{conf}->{pdf_max_uris} : DEFAULT_MAX_URIS,
+  );
+  my $pdf = Mail::SpamAssassin::PDF::Parser->new(context => $context);
   my $info = eval {
     $pdf->parse(\$data);
     $pdf->{context}->get_info();
@@ -697,9 +728,10 @@ sub handle_pdf {
   return [] if $info->{Protected};
   return [] unless $conf->{pdf_extract_images};
 
-  my $max_images = defined($conf->{pdf_max_images}) ? $conf->{pdf_max_images} : 4;
+  my $max_images = defined($conf->{pdf_max_images})
+    ? $conf->{pdf_max_images} : DEFAULT_MAX_IMAGES;
   my $max_pixels = defined($conf->{pdf_max_image_pixels})
-    ? $conf->{pdf_max_image_pixels} : 25_000_000;
+    ? $conf->{pdf_max_image_pixels} : DEFAULT_MAX_IMAGE_PIXELS;
 
   my $images = eval {
     $pdf->extract_images(max_images => $max_images, max_pixels => $max_pixels);

Modified: spamassassin/trunk/lib/Mail/SpamAssassin/PDF/Context.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/PDF/Context.pm	Tue Aug  4 18:46:58 2026	(r1936881)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/PDF/Context.pm	Tue Aug  4 20:36:17 2026	(r1936882)
@@ -5,8 +5,8 @@ use Storable qw(dclone);
 use Carp;
 
 sub new {
-    my ($class) = @_;
-    my $self = bless {}, $class;
+    my ($class,%opts) = @_;
+    my $self = bless { %opts }, $class;
     $self->reset_state();
     $self;
 }

Modified: spamassassin/trunk/lib/Mail/SpamAssassin/PDF/Context/Info.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/PDF/Context/Info.pm	Tue Aug  4 18:46:58 2026	(r1936881)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/PDF/Context/Info.pm	Tue Aug  4 20:36:17 2026	(r1936882)
@@ -6,6 +6,12 @@ use Encode qw(decode);
 
 our @ISA = qw(Mail::SpamAssassin::PDF::Context);
 
+# Default cap on the number of distinct URIs retained per PDF.  0 means no limit:
+# this layer imposes no policy of its own, so a caller using the parser directly
+# gets every URI in the document.  Callers that need a bound pass max_uris to
+# new() -- Mail::SpamAssassin::Handler::PDF does so from the pdf_max_uris setting.
+our $DEFAULT_MAX_URIS = 0;
+
 sub new {
     my $class = shift;
     my $self = $class->SUPER::new(@_);
@@ -26,6 +32,11 @@ sub new {
     # the context object, NOT in {info}, so get_info() output is unchanged.
     $self->{image_candidates} = [];
 
+    # Cap on the number of *distinct* URIs retained (see uri()).  0 disables the
+    # cap; undef means the caller did not specify one, so fall back to
+    # $DEFAULT_MAX_URIS (unlimited).
+    $self->{max_uris} = $DEFAULT_MAX_URIS unless defined $self->{max_uris};
+
     $self;
 }
 
@@ -93,10 +104,17 @@ sub draw_image {
 sub uri {
     my ($self,$location,$rect,$page) = @_;
 
-    $self->{info}->{uris}->{$location} = 1;
+    # Retain at most max_uris *distinct* URIs (0 = unlimited).
+    # LinkCount counts every link, so rules keyed on it stay accurate.
+    if ( !$self->{max_uris}
+         || keys(%{$self->{info}->{uris}}) < $self->{max_uris} ) {
+        $self->{info}->{uris}->{$location} = 1;
+    }
     $self->{info}->{LinkCount}++;
 
-    if ( defined($rect) ) {
+    # ClickArea is only accumulated for page 1, to stay consistent with
+    # PageArea (see page_begin).
+    if ( defined($rect) && defined($page->{page_number}) && $page->{page_number} == 1 ) {
         my ($x1,$y1,$x2,$y2) = @{$rect};
         if ( defined($page->{'/MediaBox'}) ) {
             # clip rectangle to media box

Modified: spamassassin/trunk/lib/Mail/SpamAssassin/PDF/Parser.pm
==============================================================================
--- spamassassin/trunk/lib/Mail/SpamAssassin/PDF/Parser.pm	Tue Aug  4 18:46:58 2026	(r1936881)
+++ spamassassin/trunk/lib/Mail/SpamAssassin/PDF/Parser.pm	Tue Aug  4 20:36:17 2026	(r1936882)
@@ -435,8 +435,14 @@ sub _parse_pages {
         # call page begin handler
         $process_page = $self->{context}->page_begin($node) if $self->{context}->can('page_begin');
 
+        # Annotations are parsed for every page, even when the context declines
+        # to process the page body.  Link annotations are where URIs live, and a
+        # phisher can put the link on any page; skipping them would hide the URI
+        # entirely.  This is cheap -- a dict lookup per annotation -- unlike the
+        # content stream rendering below.
+        $self->_parse_annotations($node->{'/Annots'},$node) if (defined($node->{'/Annots'}));
+
         if ( $process_page ) {
-            $self->_parse_annotations($node->{'/Annots'},$node) if (defined($node->{'/Annots'}));
             $node->{'/Resources'} = $self->_parse_resources($node->{'/Resources'}) if (defined($node->{'/Resources'}));
             $self->_parse_contents($node->{'/Contents'},$node) if (defined($node->{'/Contents'}));
 
@@ -565,32 +571,6 @@ sub _parse_contents {
         }
     );
 
-    if ( $context->isa('Mail::SpamAssassin::PDF::Context::Image') ) {
-        $dispatch{re} = sub { $context->rectangle(@_) };
-        $dispatch{m}  = sub { $context->path_move(@_) };
-        $dispatch{l}  = sub { $context->path_line(@_) };
-        $dispatch{h}  = sub { $context->path_close() };
-        $dispatch{n}  = sub { $context->path_end() };
-        $dispatch{c}  = sub { $context->path_curve(@_) };
-        $dispatch{v}  = sub {
-            splice @_,0,0,undef,undef;
-            $context->path_curve(@_)
-        };
-        $dispatch{y}  = sub {
-            splice @_,2,0,undef,undef;
-            $context->path_curve(@_);
-        };
-        $dispatch{s}  = sub {
-            $context->path_close();
-            $context->path_draw(1,0);
-        };
-        $dispatch{S}    = sub { $context->path_draw(1,0) };
-        $dispatch{f}    = sub { $context->path_draw(0,'nonzero') };
-        $dispatch{'f*'} = sub { $context->path_draw(0,'evenodd') };
-        $dispatch{B}    = sub { $context->path_draw(1,'nonzero') };
-        $dispatch{'B*'} = sub { $context->path_draw(1,'evenodd') };
-    }
-
     # Contents can be one of the following:
     # 1. Reference to a content stream i.e. "35 0 R"
     # 2. An array of content stream references i.e. [ "35 0 R", "36 0 R" ]

Added: spamassassin/trunk/t/data/pdf/link_page2.pdf
==============================================================================
--- /dev/null	00:00:00 1970	(empty, because file is newly added)
+++ spamassassin/trunk/t/data/pdf/link_page2.pdf	Tue Aug  4 20:36:17 2026	(r1936882)
@@ -0,0 +1,48 @@
+%PDF-1.4
+1 0 obj
+<< /Pages 2 0 R /Type /Catalog >>
+endobj
+2 0 obj
+<< /Count 2 /Kids [ 3 0 R 4 0 R ] /Type /Pages >>
+endobj
+3 0 obj
+<< /Contents 6 0 R /MediaBox [ 0 0 612 792 ] /Parent 2 0 R /Type /Page >>
+endobj
+4 0 obj
+<< /Annots [ 8 0 R ] /Contents 6 0 R /MediaBox [ 0 0 612 792 ] /Parent 2 0 R /Type /Page >>
+endobj
+5 0 obj
+<< /Length 0 >>
+stream
+
+endstream
+endobj
+6 0 obj
+<< /Length 0 >>
+stream
+
+endstream
+endobj
+8 0 obj
+<< /A 9 0 R /Border [ 0 0 0 ] /Rect [ 72 700 200 720 ] /Subtype /Link /Type /Annot >>
+endobj
+9 0 obj
+<< /S /URI /Type /Action /URI (https://example.com/page2-only.html) >>
+endobj
+xref
+0 10
+0000000000 65535 f 
+0000000009 00000 n 
+0000000058 00000 n 
+0000000123 00000 n 
+0000000212 00000 n 
+0000000319 00000 n 
+0000000368 00000 n 
+0000000000 00000 f 
+0000000417 00000 n 
+0000000518 00000 n 
+trailer
+<< /Size 10 /Root 1 0 R >>
+startxref
+604
+%%EOF

Added: spamassassin/trunk/t/data/pdf/many_uris.pdf
==============================================================================
--- /dev/null	00:00:00 1970	(empty, because file is newly added)
+++ spamassassin/trunk/t/data/pdf/many_uris.pdf	Tue Aug  4 20:36:17 2026	(r1936882)
@@ -0,0 +1,52 @@
+%PDF-1.4
+1 0 obj
+<< /Pages 2 0 R /Type /Catalog >>
+endobj
+2 0 obj
+<< /Count 1 /Kids [ 3 0 R ] /Type /Pages >>
+endobj
+3 0 obj
+<< /Annots [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R ] /Contents 4 0 R /MediaBox [ 0 0 612 792 ] /Parent 2 0 R /Type /Page >>
+endobj
+4 0 obj
+<< /Length 0 >>
+stream
+
+endstream
+endobj
+5 0 obj
+<< /A << /S /URI /Type /Action /URI (https://a.example.test/) >> /Rect [ 0 0 0 0 ] /Subtype /Link /Type /Annot >>
+endobj
+6 0 obj
+<< /A << /S /URI /Type /Action /URI (https://a.example.test/) >> /Rect [ 0 0 0 0 ] /Subtype /Link /Type /Annot >>
+endobj
+7 0 obj
+<< /A << /S /URI /Type /Action /URI (https://a.example.test/) >> /Rect [ 0 0 0 0 ] /Subtype /Link /Type /Annot >>
+endobj
+8 0 obj
+<< /A << /S /URI /Type /Action /URI (https://b.example.test/) >> /Rect [ 0 0 0 0 ] /Subtype /Link /Type /Annot >>
+endobj
+9 0 obj
+<< /A << /S /URI /Type /Action /URI (https://c.example.test/) >> /Rect [ 0 0 0 0 ] /Subtype /Link /Type /Annot >>
+endobj
+10 0 obj
+<< /A << /S /URI /Type /Action /URI (https://d.example.test/) >> /Rect [ 0 0 0 0 ] /Subtype /Link /Type /Annot >>
+endobj
+xref
+0 11
+0000000000 65535 f 
+0000000009 00000 n 
+0000000058 00000 n 
+0000000117 00000 n 
+0000000255 00000 n 
+0000000304 00000 n 
+0000000433 00000 n 
+0000000562 00000 n 
+0000000691 00000 n 
+0000000820 00000 n 
+0000000949 00000 n 
+trailer
+<< /Size 11 /Root 1 0 R >>
+startxref
+1079
+%%EOF

Modified: spamassassin/trunk/t/pdf_info.t
==============================================================================
--- spamassassin/trunk/t/pdf_info.t	Tue Aug  4 18:46:58 2026	(r1936881)
+++ spamassassin/trunk/t/pdf_info.t	Tue Aug  4 20:36:17 2026	(r1936882)
@@ -10,6 +10,8 @@
 #                         EncryptMetadata=false), all decrypted with a blank password
 #   protected           - non-blank password => Protected=1, all metrics zeroed
 #   multipage           - multi-page tree; page-1-only metrics
+#   link_page2          - /Link annotation only on page 2 (URI must still be seen)
+#   many_uris           - duplicate + distinct links; max_uris cap behaviour
 #   ascii85 / lzw       - ASCII85Decode / LZWDecode content-stream filters
 #   inline_image        - BI/ID/EI inline images, colour vs 1-bpc grayscale
 #   indirect_mediabox   - indirect /MediaBox reference + XObject image
@@ -134,13 +136,31 @@ my @tests = (
         expected => {
             'ClickArea' => 852, 'ClickRatio' => '0.18',
             'Encrypted' => 0, 'ImageArea' => 0, 'ImageCount' => 0,
-            'ImageRatio' => '0.00', 'JavaScript' => 0, 'LinkCount' => 1,
+            # LinkCount is 2: both pages carry a /Link annotation.  Annotations
+            # are parsed on every page (URIs can hide past page 1), while
+            # ClickArea stays page-1-only to match PageArea.
+            'ImageRatio' => '0.00', 'JavaScript' => 0, 'LinkCount' => 2,
             'OpenAction' => 0, 'PageArea' => 484704, 'PageCount' => 2,
             'Protected' => 0, 'Version' => '1.3',
             'uris' => { 'http://www.google.com' => 1 },
         }
     },
     {
+        # Regression: the only /Link annotation is on page 2.  Annotations used
+        # to be parsed only when the context opted into processing the page, and
+        # Context::Info opts out for every page but the first, so this URI was
+        # silently dropped.  ClickArea stays 0 -- the link is not on page 1.
+        filename => 'data/pdf/link_page2.pdf',
+        expected => {
+            'ClickArea' => 0, 'ClickRatio' => '0.00',
+            'Encrypted' => 0, 'ImageArea' => 0, 'ImageCount' => 0,
+            'ImageRatio' => '0.00', 'JavaScript' => 0, 'LinkCount' => 1,
+            'OpenAction' => 0, 'PageArea' => 484704, 'PageCount' => 2,
+            'Protected' => 0, 'Version' => '1.4',
+            'uris' => { 'https://example.com/page2-only.html' => 1 },
+        }
+    },
+    {
         filename => 'data/pdf/ascii85.pdf',
         requires => ['Convert::Ascii85'],
         expected => {
@@ -202,6 +222,40 @@ my @tests = (
         }
     },
     {
+        # 6 link annotations, 4 distinct URIs (the first is linked 3 times).
+        # No max_uris is passed, so the context applies no cap: every distinct
+        # URI is kept and the duplicates only show up in LinkCount.
+        filename => 'data/pdf/many_uris.pdf',
+        expected => {
+            'ClickArea' => 0, 'ClickRatio' => '0.00',
+            'Encrypted' => 0, 'ImageArea' => 0, 'ImageCount' => 0,
+            'ImageRatio' => '0.00', 'JavaScript' => 0, 'LinkCount' => 6,
+            'OpenAction' => 0, 'PageArea' => 484704, 'PageCount' => 1,
+            'Protected' => 0, 'Version' => '1.4',
+            'uris' => {
+                'https://a.example.test/' => 1, 'https://b.example.test/' => 1,
+                'https://c.example.test/' => 1, 'https://d.example.test/' => 1,
+            },
+        }
+    },
+    {
+        # Same fixture with max_uris=2: only the first 2 distinct URIs are kept.
+        # LinkCount still counts all 6 links, and the 3 duplicate links to
+        # a.example.test do not consume cap slots.
+        filename => 'data/pdf/many_uris.pdf',
+        max_uris => 2,
+        expected => {
+            'ClickArea' => 0, 'ClickRatio' => '0.00',
+            'Encrypted' => 0, 'ImageArea' => 0, 'ImageCount' => 0,
+            'ImageRatio' => '0.00', 'JavaScript' => 0, 'LinkCount' => 6,
+            'OpenAction' => 0, 'PageArea' => 484704, 'PageCount' => 1,
+            'Protected' => 0, 'Version' => '1.4',
+            'uris' => {
+                'https://a.example.test/' => 1, 'https://b.example.test/' => 1,
+            },
+        }
+    },
+    {
         filename => 'data/pdf/openaction_js.pdf',
         expected => {
             'ClickArea' => 0, 'ClickRatio' => '0.00',
@@ -227,7 +281,9 @@ for my $test (@tests) {
         next;
     }
 
-    my $context = Mail::SpamAssassin::PDF::Context::Info->new();
+    my $context = Mail::SpamAssassin::PDF::Context::Info->new(
+        defined($test->{max_uris}) ? (max_uris => $test->{max_uris}) : ()
+    );
     my $pdf = Mail::SpamAssassin::PDF::Parser->new( context => $context );
 
     my $data = get_file_contents($test->{filename});
@@ -243,7 +299,9 @@ for my $test (@tests) {
 
     my $info = $context->get_info();
 
-    is_deeply $info, $test->{expected}, $test->{filename};
+    my $name = $test->{filename};
+    $name .= " (max_uris=$test->{max_uris})" if defined $test->{max_uris};
+    is_deeply $info, $test->{expected}, $name;
 }
 
 # Return a comma-joined list of the modules in $required that cannot be loaded,
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.