This is an automated email from the ASF dual-hosted git repository.

moonchen pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new adbd85d870 prefetch: don't drop replacements for non-participating 
optional capture groups (#13352)
adbd85d870 is described below

commit adbd85d8707a674538a7ee2fe8816a8ab07c159d
Author: Mo Chen <[email protected]>
AuthorDate: Mon Aug 10 11:34:17 2026 -0500

    prefetch: don't drop replacements for non-participating optional capture 
groups (#13352)
    
    Pattern::replace() rejected any $N whose index was >= the match's
    return value. That value is one past the highest capture group that
    *participated*, not the number of groups the pattern defines, so a
    trailing optional group such as "(\?.*)?" that did not participate made
    a valid $N look out of range.  Every such request logged "invalid
    reference in replacement string" and silently dropped the prefetch.
    
    Validate $N once at config-load time against the pattern's actual
    capture-group count, and substitute an empty string at match time for
    a group that did not participate, per PCRE2 semantics.
    
    Also treat an unusable --fetch-path-pattern and invalid --fetch-count
    / --fetch-max / --fetch-overflow values as configuration errors, so
    the remap rule is refused at load rather than running with prefetch
    silently disabled. --fetch-count and --fetch-max now parse with
    std::from_chars(), which rejects a value above UINT_MAX instead of
    truncating it. Skip an empty expanded path rather than self-
    prefetching the original, and report that once per remap instance
    since the condition is request-dependent.
    
    Removes the now-dead Pattern::process() and Pattern::capture().
---
 doc/admin-guide/plugins/prefetch.en.rst            |   7 ++
 plugins/prefetch/configs.cc                        |  60 +++++++++--
 plugins/prefetch/configs.h                         |  27 ++++-
 plugins/prefetch/pattern.cc                        | 120 ++++++---------------
 plugins/prefetch/pattern.h                         |   2 -
 plugins/prefetch/plugin.cc                         |  13 +++
 .../prefetch/prefetch_bad_count_refused.test.py    |  53 +++++++++
 .../prefetch/prefetch_bad_pattern_refused.test.py  |  54 ++++++++++
 .../prefetch/prefetch_empty_replacement.test.py    |  90 ++++++++++++++++
 .../prefetch/prefetch_optional_group.gold          |   4 +
 .../prefetch/prefetch_optional_group.test.py       |  95 ++++++++++++++++
 11 files changed, 427 insertions(+), 98 deletions(-)

diff --git a/doc/admin-guide/plugins/prefetch.en.rst 
b/doc/admin-guide/plugins/prefetch.en.rst
index 4f6bf7a759..62efb348c9 100644
--- a/doc/admin-guide/plugins/prefetch.en.rst
+++ b/doc/admin-guide/plugins/prefetch.en.rst
@@ -293,6 +293,13 @@ Plugin parameters
     * if ``true`` the fetch policy would use the **next** URL's cache key that 
to find out if the **next object** should be prefetched or not
 * ``--log-name`` - specifies a custom log name (if not specified a log is not 
created)
 
+An invalid parameter value is a configuration error. ``--fetch-count`` and 
``--fetch-max`` must be
+decimal numbers that fit in an unsigned integer, ``--fetch-overflow`` must be 
``32`` or ``64``, and
+``--fetch-path-pattern`` must compile and may only reference capture groups 
that the pattern
+defines. A remap rule with an invalid value fails to load, instead of loading 
with prefetch
+silently disabled. ``traffic_ctl config reload`` rejects such a configuration 
and keeps the running
+one.
+
 Metrics
 =======
 
diff --git a/plugins/prefetch/configs.cc b/plugins/prefetch/configs.cc
index 8d74e7585e..9e86a9a2bc 100644
--- a/plugins/prefetch/configs.cc
+++ b/plugins/prefetch/configs.cc
@@ -21,6 +21,8 @@
  * @brief Plugin configuration.
  */
 
+#include <charconv>  /* std::from_chars() */
+#include <cstring>   /* strlen() */
 #include <fstream>   /* std::ifstream */
 #include <getopt.h>  /* getopt_long() */
 #include <sstream>   /* std::istringstream */
@@ -71,14 +73,44 @@ iequals(const StringView lhs, const StringView rhs)
                     [](const char a, const char b) { return tolower(a) == 
tolower(b); });
 }
 
-void
+bool
 PrefetchConfig::setFetchOverflow(const char *optarg)
 {
-  if (StringView("64") == optarg) {
+  if (nullptr == optarg) {
+    return false;
+  }
+  if (StringView("32") == optarg) {
+    _fetchOverflow = EvalPolicy::Overflow32;
+  } else if (StringView("64") == optarg) {
     _fetchOverflow = EvalPolicy::Overflow64;
   } else if (iequals("bignum", optarg)) {
     _fetchOverflow = EvalPolicy::Bignum;
+  } else {
+    return false;
   }
+  return true;
+}
+
+/**
+ * @brief Parses @a optarg as an unsigned integer option value.
+ * @param optarg the option value to parse.
+ * @param value set to the parsed value on success, untouched on failure.
+ * @return true if @a optarg is a non-empty string of decimal digits that fits 
in @a value.
+ */
+static bool
+parseUnsignedInt(const char *optarg, unsigned &value)
+{
+  if (nullptr == optarg) {
+    return false;
+  }
+
+  const char *const end = optarg + strlen(optarg);
+  auto const [parsed, ec]{std::from_chars(optarg, end, value)};
+
+  /* from_chars() reports a leading sign or a non-digit as invalid_argument 
and a value too large
+   * for @a value as result_out_of_range. Requiring it to consume the whole 
string rejects trailing
+   * characters such as "10abc". */
+  return std::errc{} == ec && parsed == end;
 }
 
 /**
@@ -147,7 +179,12 @@ PrefetchConfig::init(int argc, char *argv[])
       break;
 
     case 'c': /* --fetch-count */
-      setFetchCount(optarg);
+      if (unsigned count = 0; parseUnsignedInt(optarg, count)) {
+        setFetchCount(count);
+      } else {
+        PrefetchError("invalid --fetch-count '%s': expected a non-negative 
integer", optarg ? optarg : "");
+        status = false;
+      }
       break;
 
     case 'e': /* --fetch-path-pattern */ {
@@ -156,7 +193,10 @@ PrefetchConfig::init(int argc, char *argv[])
         if (pattern->init(optarg)) {
           _nextPaths.add(std::move(pattern));
         } else {
-          PrefetchError("failed to initialize next object pattern");
+          /* An unusable fetch-path-pattern is a configuration error; fail 
instance creation so ATS
+           * refuses to load the remap rule rather than silently running with 
prefetch disabled. */
+          PrefetchError("failed to initialize fetch-path-pattern '%s'", optarg 
? optarg : "");
+          status = false;
         }
       }
     } break;
@@ -166,11 +206,19 @@ PrefetchConfig::init(int argc, char *argv[])
     } break;
 
     case 'x': /* --fetch-max */
-      setFetchMax(optarg);
+      if (unsigned max = 0; parseUnsignedInt(optarg, max)) {
+        setFetchMax(max);
+      } else {
+        PrefetchError("invalid --fetch-max '%s': expected a non-negative 
integer", optarg ? optarg : "");
+        status = false;
+      }
       break;
 
     case 'o': /* --fetch-overflow */
-      setFetchOverflow(optarg);
+      if (!setFetchOverflow(optarg)) {
+        PrefetchError("invalid --fetch-overflow '%s': expected 32, 64, or 
bignum", optarg ? optarg : "");
+        status = false;
+      }
       break;
 
     case 'r': /* --replace-host */
diff --git a/plugins/prefetch/configs.h b/plugins/prefetch/configs.h
index e36e12cdb7..3ad3980f38 100644
--- a/plugins/prefetch/configs.h
+++ b/plugins/prefetch/configs.h
@@ -23,6 +23,7 @@
 
 #pragma once
 
+#include <atomic>
 #include <string>
 
 #include "common.h"
@@ -119,9 +120,9 @@ public:
   }
 
   void
-  setFetchCount(const char *optarg)
+  setFetchCount(unsigned count)
   {
-    _fetchCount = getValue(optarg);
+    _fetchCount = count;
   }
 
   unsigned
@@ -131,9 +132,9 @@ public:
   }
 
   void
-  setFetchMax(const char *optarg)
+  setFetchMax(unsigned max)
   {
-    _fetchMax = getValue(optarg);
+    _fetchMax = max;
   }
 
   unsigned
@@ -142,7 +143,7 @@ public:
     return _fetchMax;
   }
 
-  void setFetchOverflow(const char *optarg);
+  bool setFetchOverflow(const char *optarg);
 
   EvalPolicy
   getFetchOverflow() const
@@ -180,6 +181,20 @@ public:
     return _nextPaths;
   }
 
+  /**
+   * @brief Whether an expanded path that collapsed to empty should be 
reported.
+   *
+   * Whether the replacement collapses depends on the request, so the 
condition recurs per
+   * transaction for as long as the pattern stays misconfigured. Report it 
once per remap instance
+   * so a bad pattern is visible without flooding the error log. A config 
reload builds a new
+   * instance and reports again.
+   */
+  bool
+  shouldReportEmptyPath()
+  {
+    return !_reportedEmptyPath.exchange(true, std::memory_order_relaxed);
+  }
+
   void
   setLogName(const char *optarg)
   {
@@ -226,4 +241,6 @@ private:
   bool         _exactMatch    = false;
   bool         _cmcd_nor      = false;
   MultiPattern _nextPaths;
+
+  std::atomic<bool> _reportedEmptyPath{false}; /* see shouldReportEmptyPath() 
*/
 };
diff --git a/plugins/prefetch/pattern.cc b/plugins/prefetch/pattern.cc
index 22926c42f4..f73a7ff224 100644
--- a/plugins/prefetch/pattern.cc
+++ b/plugins/prefetch/pattern.cc
@@ -130,45 +130,6 @@ Pattern::empty() const
   return _pattern.empty() || _regex.empty();
 }
 
-/**
- * @brief Capture or capture-and-replace depending on whether a replacement 
string is specified.
- * @see replace()
- * @see capture()
- * @param subject PCRE2 subject string
- * @param result vector of strings where the result of captures or the 
replacements will be returned.
- * @return true if there was a match and capture or replacement succeeded, 
false if failure.
- */
-bool
-Pattern::process(const String &subject, StringVector &result)
-{
-  if (!_replacement.empty()) {
-    /* Replacement pattern was provided in the configuration - capture and 
replace. */
-    String element;
-    if (replace(subject, element)) {
-      result.push_back(element);
-    } else {
-      return false;
-    }
-  } else {
-    /* Replacement was not provided so return all capturing groups except the 
group zero. */
-    StringVector captures;
-    if (capture(subject, captures)) {
-      if (captures.size() == 1) {
-        result.push_back(captures[0]);
-      } else {
-        StringVector::iterator it = captures.begin() + 1;
-        for (; it != captures.end(); it++) {
-          result.push_back(*it);
-        }
-      }
-    } else {
-      return false;
-    }
-  }
-
-  return true;
-}
-
 /**
  * @brief PCRE2 matches a subject string against the regex pattern.
  * @param subject PCRE2 subject
@@ -195,39 +156,6 @@ Pattern::match(const String &subject)
   return true;
 }
 
-/**
- * @brief Return all PCRE2 capture groups that matched in the subject string
- * @param subject PCRE2 subject string
- * @param result reference to vector of strings containing all capture groups
- */
-bool
-Pattern::capture(const String &subject, StringVector &result)
-{
-  PrefetchDebug("matching '%s' to '%s'", _pattern.c_str(), subject.c_str());
-
-  if (_regex.empty()) {
-    return false;
-  }
-
-  RegexMatches matches;
-  int          matchCount = _regex.exec(subject, matches, RE_NOTEMPTY);
-
-  if (matchCount <= 0) {
-    if (matchCount != RE_ERROR_NOMATCH) {
-      PrefetchError("matching error %d", matchCount);
-    }
-    return false;
-  }
-
-  for (int i = 0; i < matchCount; i++) {
-    std::string_view match = matches[i];
-    result.emplace_back(match.data(), match.length());
-    PrefetchDebug("capturing '%s' %d", result.back().c_str(), i);
-  }
-
-  return true;
-}
-
 /**
  * @brief Replaces all replacements found in the replacement string with what 
matched in the PCRE2 capturing groups.
  * @param subject PCRE2 subject string
@@ -253,25 +181,22 @@ Pattern::replace(const String &subject, String &result)
     return false;
   }
 
-  /* Verify the replacement has the right number of matching groups */
-  for (int i = 0; i < _tokenCount; i++) {
-    if (_tokens[i] >= matchCount) {
-      PrefetchError("invalid reference in replacement string: $%d", 
_tokens[i]);
-      return false;
-    }
-  }
-
   int previous = 0;
   for (int i = 0; i < _tokenCount; i++) {
-    int              replIndex = _tokens[i];
-    std::string_view dst       = matches[replIndex];
+    int replIndex = _tokens[i];
 
-    String src(_replacement, _tokenOffset[i], 2);
+    /* $replIndex was validated at config-load time against the number of 
groups the pattern defines, but
+     * the group may still not have participated in *this* match (e.g. a 
trailing optional group such as
+     * "(\?.*)?" when the subject has no query string).  pcre2_match() returns 
one past the highest
+     * participating group, so substitute an empty string for a group at or 
beyond that -- the documented
+     * PCRE2 semantics for an unmatched group -- rather than failing the whole 
replacement.  Use ""
+     * rather than a default-constructed view so data() is never null, which 
"%.*s" requires. */
+    std::string_view dst = (replIndex < matchCount) ? matches[replIndex] : 
std::string_view{""};
 
-    PrefetchDebug("replacing '%s' with '%.*s'", src.c_str(), 
static_cast<int>(dst.length()), dst.data());
+    PrefetchDebug("replacing '$%d' with '%.*s'", replIndex, 
static_cast<int>(dst.length()), dst.data());
 
     result.append(_replacement, previous, _tokenOffset[i] - previous);
-    result.append(dst.data(), dst.length());
+    result.append(dst);
 
     previous = _tokenOffset[i] + 2; /* 2 is the size of $0 or $1 or $2, ... or 
$9 */
   }
@@ -331,6 +256,31 @@ Pattern::compile()
     }
   }
 
+  /* Validate replacement references against the number of capture groups the 
pattern actually defines
+   * (not how many happen to participate in any given match) at config-load 
time.  This catches a
+   * genuinely out-of-range reference such as $5 against a 3-group pattern, 
and a pattern that defines
+   * more groups than can be captured -- RegexMatches holds the whole match 
plus TOKENCOUNT-1 groups. */
+  if (success) {
+    int32_t captureCount = _regex.get_capture_count();
+    if (captureCount < 0) {
+      PrefetchError("failed to get capture count for regex '%s'", 
_pattern.c_str());
+      success = false;
+    } else if (captureCount > TOKENCOUNT - 1) {
+      PrefetchError("regex '%s' defines %d capture groups; the prefetch plugin 
supports at most %d (references $0..$%d)",
+                    _pattern.c_str(), captureCount, TOKENCOUNT - 1, TOKENCOUNT 
- 1);
+      success = false;
+    } else {
+      for (int i = 0; i < _tokenCount; i++) {
+        if (_tokens[i] > captureCount) {
+          PrefetchError("invalid reference $%d in replacement '%s': pattern 
defines only %d group(s)", _tokens[i],
+                        _replacement.c_str(), captureCount);
+          success = false;
+          break;
+        }
+      }
+    }
+  }
+
   return success;
 }
 
diff --git a/plugins/prefetch/pattern.h b/plugins/prefetch/pattern.h
index 1105c17305..43f8ea5b15 100644
--- a/plugins/prefetch/pattern.h
+++ b/plugins/prefetch/pattern.h
@@ -40,9 +40,7 @@ public:
   bool init(const String &config);
   bool empty() const;
   bool match(const String &subject);
-  bool capture(const String &subject, StringVector &result);
   bool replace(const String &subject, String &result);
-  bool process(const String &subject, StringVector &result);
 
 private:
   bool compile();
diff --git a/plugins/prefetch/plugin.cc b/plugins/prefetch/plugin.cc
index 37c8d60a17..22fe984f3d 100644
--- a/plugins/prefetch/plugin.cc
+++ b/plugins/prefetch/plugin.cc
@@ -663,6 +663,19 @@ contHandleFetch(const TSCont contp, TSEvent event, void 
*edata)
               String expandedPath;
 
               if (config.getNextPath().replace(workingPath, expandedPath)) {
+                if (expandedPath.empty()) {
+                  /* A replacement that collapses to empty (e.g. every 
referenced group was optional and
+                   * absent) would otherwise be scheduled with a zero-length 
path, which BgFetch skips --
+                   * leaving the original request path in place and 
prefetching the pristine URL itself.
+                   * Stop rather than issue that self-prefetch. Report once 
per instance; whether the
+                   * replacement collapses depends on the request, so this 
recurs per transaction. */
+                  if (config.shouldReportEmptyPath()) {
+                    PrefetchError("prefetch pattern produced an empty path; 
check the fetch-path-pattern replacement");
+                  } else {
+                    PrefetchDebug("prefetch pattern produced an empty path");
+                  }
+                  break;
+                }
                 PrefetchDebug("replaced: %s", expandedPath.c_str());
                 expand(expandedPath, config.getFetchOverflow());
                 PrefetchDebug("expanded: %s cachekey: %s", 
expandedPath.c_str(), data->_cachekey.c_str());
diff --git 
a/tests/gold_tests/pluginTest/prefetch/prefetch_bad_count_refused.test.py 
b/tests/gold_tests/pluginTest/prefetch/prefetch_bad_count_refused.test.py
new file mode 100644
index 0000000000..421e07e1c5
--- /dev/null
+++ b/tests/gold_tests/pluginTest/prefetch/prefetch_bad_count_refused.test.py
@@ -0,0 +1,53 @@
+'''
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+Test.Summary = '''
+Test that prefetch.so treats a --fetch-count that does not fit in an unsigned 
as a configuration
+error: ATS refuses to load the remap rule (and fails to start) instead of 
truncating the value.
+
+The value below is a string of decimal digits, so it passes a digits-only 
check, but it exceeds the
+range of the unsigned the plugin stores it in.
+'''
+
+ts = Test.MakeATSProcess("ts")
+ts.Disk.records_config.update({
+    'proxy.config.diags.debug.enabled': 1,
+    'proxy.config.diags.debug.tags': 'prefetch',
+})
+ts.Disk.remap_config.AddLine(
+    "map http://domain.in http://127.0.0.1:8080"; + " @plugin=prefetch.so" + " 
@pparam=--front=true" +
+    " @pparam=--fetch-policy=simple" + " @pparam=--fetch-count=5000000000")
+
+ts.ReturnCode = 33  # Emergency exit: remap.config failed to load.
+ts.Ready = 0
+# ATS is expected to log the rejection; this ContainsExpression both asserts 
it and replaces the
+# default "diags.log must not contain ERROR:" check (the rejection is logged 
via TSError).
+ts.Disk.diags_log.Content = Testers.ContainsExpression(
+    "invalid --fetch-count '5000000000'", "an out-of-range fetch-count must be 
rejected at config load")
+
+tr = Test.AddTestRun("prefetch rejects an out-of-range fetch-count at load")
+# Wait for the rejection message with a separate watcher: gating ts readiness 
on the log line directly
+# can race the process exiting before autest observes the line.
+watcher = Test.Processes.Process("watcher")
+watcher.Command = "sleep 30"
+watcher.Ready = When.FileContains(ts.Disk.diags_log.Name, "invalid 
--fetch-count '5000000000'")
+watcher.StartBefore(ts)
+
+tr.Processes.Default.Command = "echo done"
+tr.TimeOut = 30
+tr.Processes.Default.StartBefore(watcher)
diff --git 
a/tests/gold_tests/pluginTest/prefetch/prefetch_bad_pattern_refused.test.py 
b/tests/gold_tests/pluginTest/prefetch/prefetch_bad_pattern_refused.test.py
new file mode 100644
index 0000000000..01ddbfd1a2
--- /dev/null
+++ b/tests/gold_tests/pluginTest/prefetch/prefetch_bad_pattern_refused.test.py
@@ -0,0 +1,54 @@
+'''
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+Test.Summary = '''
+Test that prefetch.so treats an unusable fetch-path-pattern as a configuration 
error: ATS refuses to
+load the remap rule (and fails to start) instead of silently running with 
prefetch disabled.
+
+The pattern below defines 10 capture groups.  The plugin's ovector (OVECOUNT = 
TOKENCOUNT*3) can hold
+offsets for the whole match plus at most TOKENCOUNT-1 (9) groups, so the 
pattern is rejected at
+config-load time; that fails the remap instance, and remap.config fails to 
load.
+'''
+
+ts = Test.MakeATSProcess("ts")
+ts.Disk.records_config.update({
+    'proxy.config.diags.debug.enabled': 1,
+    'proxy.config.diags.debug.tags': 'prefetch',
+})
+ts.Disk.remap_config.AddLine(
+    "map http://domain.in http://127.0.0.1:8080"; + " @plugin=prefetch.so" + " 
@pparam=--front=true" +
+    " @pparam=--fetch-policy=simple" + r" 
@pparam=--fetch-path-pattern=/(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)/$1/")
+
+ts.ReturnCode = 33  # Emergency exit: remap.config failed to load.
+ts.Ready = 0
+# ATS is expected to log the rejection; this ContainsExpression both asserts 
it and replaces the
+# default "diags.log must not contain ERROR:" check (the rejection is logged 
via TSError).
+ts.Disk.diags_log.Content = Testers.ContainsExpression(
+    "defines 10 capture groups", "over-limit fetch-path-pattern must be 
rejected at config load")
+
+tr = Test.AddTestRun("prefetch rejects an over-limit capture-group pattern at 
load")
+# Wait for the rejection message with a separate watcher: gating ts readiness 
on the log line directly
+# can race the process exiting before autest observes the line.
+watcher = Test.Processes.Process("watcher")
+watcher.Command = "sleep 30"
+watcher.Ready = When.FileContains(ts.Disk.diags_log.Name, "defines 10 capture 
groups")
+watcher.StartBefore(ts)
+
+tr.Processes.Default.Command = "echo done"
+tr.TimeOut = 30
+tr.Processes.Default.StartBefore(watcher)
diff --git 
a/tests/gold_tests/pluginTest/prefetch/prefetch_empty_replacement.test.py 
b/tests/gold_tests/pluginTest/prefetch/prefetch_empty_replacement.test.py
new file mode 100644
index 0000000000..4556e52b59
--- /dev/null
+++ b/tests/gold_tests/pluginTest/prefetch/prefetch_empty_replacement.test.py
@@ -0,0 +1,90 @@
+'''
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+Test.Summary = '''
+Test prefetch.so does not self-prefetch when a valid pattern's replacement 
collapses to an empty path.
+
+The replacement here is just "$3", where group 3 ("(\\?.*)?") is optional.  
For a query-less request
+that group is absent, so the replacement expands to the empty string.  An 
empty expanded path would
+otherwise be scheduled with zero length, which BgFetch skips -- leaving the 
pristine request path and
+prefetching the URL itself.  The plugin must instead log and stop; the client 
request is still served.
+'''
+
+server = Test.MakeOriginServer("server")
+for i in list(range(1, 1 + 2)):
+    request_header = {
+        "headers":
+            f"GET /texts/demo-{i} HTTP/1.1\r\n"
+            "Host: does.not.matter\r\n"  # But cannot be omitted.
+            "\r\n",
+        "timestamp": "1469733493.993",
+        "body": ""
+    }
+    response_header = {
+        "headers": "HTTP/1.1 200 OK\r\n"
+                   "Connection: close\r\n"
+                   "Cache-control: max-age=85000\r\n"
+                   "\r\n",
+        "timestamp": "1469733493.993",
+        "body": f"This is the body for demo-{i}.\n"
+    }
+    server.addResponse("sessionlog.json", request_header, response_header)
+
+dns = Test.MakeDNServer("dns")
+
+ts = Test.MakeATSProcess("ts")
+ts.Disk.records_config.update(
+    {
+        'proxy.config.diags.debug.enabled': 1,
+        'proxy.config.diags.debug.tags': 'http|dns|prefetch',
+        'proxy.config.dns.nameservers': f"127.0.0.1:{dns.Variables.Port}",
+        'proxy.config.dns.resolv_conf': "NULL",
+    })
+# A valid pattern (3 defined groups, $3 in range) whose replacement is only 
the optional $3 group.
+ts.Disk.remap_config.AddLine(
+    f"map http://domain.in http://127.0.0.1:{server.Variables.Port}"; + " 
@plugin=cachekey.so @pparam=--remove-all-params=true"
+    " @plugin=prefetch.so" + " @pparam=--front=true" + " 
@pparam=--fetch-policy=simple" +
+    r" @pparam=--fetch-path-pattern=/(.*-)(\d+)(\?.*)?$/$3/" + " 
@pparam=--fetch-count=3")
+ts.ReturnCode = Any(0, -2)
+
+# The empty replacement must be logged and skipped.  This ContainsExpression 
both asserts the message
+# and replaces the default "diags.log must not contain ERROR:" check (it is 
logged via TSError).
+ts.Disk.diags_log.Content = Testers.ContainsExpression(
+    "produced an empty path", "an empty replacement must be logged and 
skipped, not self-prefetched")
+
+tr = Test.AddTestRun()
+tr.Processes.Default.StartBefore(server)
+tr.Processes.Default.StartBefore(dns)
+tr.Processes.Default.StartBefore(ts)
+tr.Processes.Default.Command = 'echo start TS, HTTP server and DNS.'
+tr.Processes.Default.ReturnCode = 0
+
+# The client request is still served normally even though the prefetch is 
skipped.
+tr = Test.AddTestRun()
+tr.MakeCurlCommand(f'--verbose --proxy 127.0.0.1:{ts.Variables.port} 
http://domain.in/texts/demo-1')
+tr.Processes.Default.ReturnCode = 0
+
+Test.AddAwaitFileContainsTestRun('Await the empty-path skip to be logged.', 
ts.Disk.diags_log.Name, 'produced an empty path')
+
+# The self-prefetch that the old code issued would re-fetch the original path. 
 With the fix the loop
+# stops before scheduling, so "failed to process the pattern" (the old 
second-iteration symptom on the
+# emptied working path) must never appear.
+tr = Test.AddTestRun()
+tr.Processes.Default.Command = (f"grep -c 'failed to process the pattern' 
{ts.Disk.traffic_out.Name} || true")
+tr.Streams.stdout = Testers.ContainsExpression("0", "no per-request 
pattern-processing failure")
+tr.Processes.Default.ReturnCode = 0
diff --git a/tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.gold 
b/tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.gold
new file mode 100644
index 0000000000..5cd6cb5de9
--- /dev/null
+++ b/tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.gold
@@ -0,0 +1,4 @@
+GET http://domain.in/texts/demo-1 HTTP/1.1
+GET http://domain.in/texts/demo-2 HTTP/1.1
+GET http://domain.in/texts/demo-3 HTTP/1.1
+GET http://domain.in/texts/demo-4 HTTP/1.1
diff --git 
a/tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.test.py 
b/tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.test.py
new file mode 100644
index 0000000000..813a6b640a
--- /dev/null
+++ b/tests/gold_tests/pluginTest/prefetch/prefetch_optional_group.test.py
@@ -0,0 +1,95 @@
+'''
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+Test.Summary = '''
+Test prefetch.so with an optional trailing capture group that does not 
participate in the match.
+
+The fetch-path-pattern below ends in an optional "(\\?.*)?" group that only 
participates when the
+subject carries a query string.  For a query-less request that group is 
absent, so pcre_exec() returns
+3 -- one past the highest *participating* group -- even though the pattern 
defines a 3rd group that the
+replacement references as $3.  The old code compared $3 against that return 
value and wrongly rejected
+it; a non-participating group must instead substitute an empty string rather 
than fail the whole
+replacement (which previously logged "invalid reference in replacement string: 
$3" and silently
+dropped every prefetch).  Mirrors the production hls/mvod remap pattern
+"/(.*-)(\\.m3u8)(\\?.*)?$/$1-0.mp4$3/".
+'''
+
+server = Test.MakeOriginServer("server")
+for i in list(range(1, 1 + 4)):
+    request_header = {
+        "headers":
+            f"GET /texts/demo-{i} HTTP/1.1\r\n"
+            "Host: does.not.matter\r\n"  # But cannot be omitted.
+            "\r\n",
+        "timestamp": "1469733493.993",
+        "body": ""
+    }
+    response_header = {
+        "headers": "HTTP/1.1 200 OK\r\n"
+                   "Connection: close\r\n"
+                   "Cache-control: max-age=85000\r\n"
+                   "\r\n",
+        "timestamp": "1469733493.993",
+        "body": f"This is the body for demo-{i}.\n"
+    }
+    server.addResponse("sessionlog.json", request_header, response_header)
+
+dns = Test.MakeDNServer("dns")
+
+ts = Test.MakeATSProcess("ts")
+ts.Disk.records_config.update(
+    {
+        'proxy.config.diags.debug.enabled': 1,
+        'proxy.config.diags.debug.tags': 'http|dns|prefetch',
+        'proxy.config.dns.nameservers': f"127.0.0.1:{dns.Variables.Port}",
+        'proxy.config.dns.resolv_conf': "NULL",
+    })
+ts.Disk.remap_config.AddLine(
+    f"map http://domain.in http://127.0.0.1:{server.Variables.Port}"; + " 
@plugin=cachekey.so @pparam=--remove-all-params=true"
+    " @plugin=prefetch.so" + " @pparam=--front=true" + " 
@pparam=--fetch-policy=simple" +
+    r" @pparam=--fetch-path-pattern=/(.*-)(\d+)(\?.*)?$/$1{$2+1}$3/" + " 
@pparam=--fetch-count=3")
+ts.ReturnCode = Any(0, -2)
+
+# Belt-and-suspenders next to the gold comparison (which is the primary 
guard): a regression that
+# re-introduces a per-request rejection of the non-participating $3 logs an 
"invalid reference ..."
+# error.  This pattern is valid (3 defined groups, $3 in range) so the 
compile-time validator never
+# fires either, hence no "invalid reference" text should ever reach the log.
+ts.Disk.traffic_out.Content = Testers.ExcludesExpression(
+    "invalid reference", "optional non-participating group must not fail the 
replacement")
+
+tr = Test.AddTestRun()
+tr.Processes.Default.StartBefore(server)
+tr.Processes.Default.StartBefore(dns)
+tr.Processes.Default.StartBefore(ts)
+tr.Processes.Default.Command = 'echo start TS, HTTP server and DNS.'
+tr.Processes.Default.ReturnCode = 0
+
+tr = Test.AddTestRun()
+tr.MakeCurlCommand(f'--verbose --proxy 127.0.0.1:{ts.Variables.port} 
http://domain.in/texts/demo-1')
+tr.Processes.Default.ReturnCode = 0
+
+# The original request and the three prefetches are logged independently and 
may finish out of
+# order, so wait for every expected URL to be logged before comparing, and 
sort both sides so the
+# comparison does not depend on completion order.
+for tag in ['demo-1', 'demo-2', 'demo-3', 'demo-4']:
+    Test.AddAwaitFileContainsTestRun(f'Await {tag} to be logged.', 
ts.Disk.traffic_out.Name, tag)
+
+tr = Test.AddTestRun()
+tr.Processes.Default.Command = (f"grep 'GET http://domain.in' 
{ts.Disk.traffic_out.Name} | sort")
+tr.Streams.stdout = "prefetch_optional_group.gold"
+tr.Processes.Default.ReturnCode = 0

Reply via email to