Repository: xroche/httrack (2026-07-27T12:00:48Z)

Issue:
Backward scans from strlen(s) - 1 walk off the front of the buffer
Two loops start at the last character of a string and decrement with no lower bound, so both run past the front when the string is nothing but separators.

`optinclude_file()` (htsalias.c) right-trims every config line:

```c
a = line + strlen(line) - 1;
while(is_realspace(*a))
  *(a--) = '\0';
```

A line of nothing but spaces in a `httrackrc` walks below the 256-byte stack array `line`, reading each byte on the way down and writing a NUL over it whenever that byte happens to be whitespace. With `printf '   \nthisisnotanoption\n' > .httrackrc` and an ASan build:

```
ERROR: AddressSanitizer: stack-buffer-overflow
READ of size 1 at 0x7fe6a1e010ff thread T0
    #0 in optinclude_file src/htsalias.c:509
    #1 in hts_main_internal src/htscoremain.c:451
    [4352, 4608) 'line' (line 494) <== Memory access at offset 4351 underflows this variable
```

`url_savename()` (htsname.c) does the same over digits, in the collision rename:

```c
b = tempo + strlen(tempo) - 1;
while(isdigit((unsigned char) *b))
  b--;
if (*b == collisionSeparator) { ... *b = '\0'; }
```

That one needs no empty string, only an all-digit name, and a crawled page can supply it. Two pages both named `2024.html` under `-g` collide, the second goes through the `-<n>` rename, and the scan then has nothing but digits to walk over:

```
httrack -g "file://.../a/2024.html" "file://.../b/2024.html"

ERROR: AddressSanitizer: stack-buffer-overflow
READ of size 1
    #0 in url_savename src/htsname.c:1703
    #1 in htsparse src/htsparse.c:2521
    #2 in httpmirror src/htscore.c:1807
```

Both are out-of-bounds reads, and both turn into a one-byte out-of-bounds write when the byte below the buffer matches (whitespace for the first, the collision separator for the second).

`treathead()` (htslib.c) carries a third copy of the loop over `Content-Disposition`, bounded only by the `=` the scan cannot walk past.

Related but distinct from #770 and #781, which are about `x[strlen(x) - 1]` and `x + strlen(x) - 1` underflowing on an empty string. Here the pointer is formed on a non-empty string; it is the unbounded decrement that leaves the buffer.


Discussion:

Patch:
diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml
index 056b8445..10a0268d 100644
--- a/.github/workflows/windows-build.yml
+++ b/.github/workflows/windows-build.yml
@@ -211,7 +211,8 @@ jobs:
           pass=0 fail=0 skip=0 failed="" skipped="" deadline=0
           for t in 00_runnable.test 01_engine-*.test 01_zlib-*.test \
                    *_local-*.test 13_crawl_proxy_https.test 58_watchdog.test \
-                   60_crawl-log-salvage.test 106_engine-repair-rename.test; do
+                   60_crawl-log-salvage.test 116_engine-rtrim.test \
+                   106_engine-repair-rename.test; do
             elapsed=$((SECONDS - started))
             if [ "$elapsed" -ge "$suite_deadline" ]; then
               echo "::error::suite deadline: ${elapsed}s elapsed, stopping before $t"
diff --git a/src/htsalias.c b/src/htsalias.c
index 7b4052f5..9b501974 100644
--- a/src/htsalias.c
+++ b/src/htsalias.c
@@ -501,13 +501,11 @@ int optinclude_file(const char *name, int *argc, char **argv, char *x_argvblk,
       /* read line */
       linput(fp, line, 250);
       hts_lowcase(line);
+      /* trim first: a blank line is skipped, not parsed as an option */
+      hts_rtrim(line, HTS_REALSPACES);
       if (strnotempty(line)) {
         /* no comment line: # // ; */
         if (strchr("#/;", line[0]) == NULL) {
-          /* right trim */
-          a = line + strlen(line) - 1;
-          while(is_realspace(*a))
-            *(a--) = '\0';
           /* jump "set " and spaces */
           a = line;
           while(is_realspace(*a))
@@ -577,7 +575,6 @@ int optinclude_file(const char *name, int *argc, char **argv, char *x_argvblk,
             }
           }
         }
-
       }
     }
     fclose(fp);
diff --git a/src/htsbauth.c b/src/htsbauth.c
index 79c3e053..c24c7265 100644
--- a/src/htsbauth.c
+++ b/src/htsbauth.c
@@ -491,7 +491,7 @@ char *bauth_prefix(char *prefix, const char *adr, const char *fil) {
   if (a)
     *a = '\0';
   if (strchr(prefix, '/')) {
-    a = prefix + strlen(prefix) - 1;
+    a = hts_lastcharptr(prefix);
     while(*a != '/')
       a--;
     *(a + 1) = '\0';
diff --git a/src/htscore.c b/src/htscore.c
index 83177242..1e30074d 100644
--- a/src/htscore.c
+++ b/src/htscore.c
@@ -3774,8 +3774,7 @@ int htsAddLink(htsmoduleStruct * str, char *link) {
         strcpybuff(codebase, heap(ptr)->fil);
       else
         strcpybuff(codebase, heap(heap(ptr)->precedent)->fil);
-      // empty codebase has no last char; codebase-1 would underflow
-      a = codebase[0] != '\0' ? codebase + strlen(codebase) - 1 : codebase;
+      a = hts_lastcharptr(codebase);
       while((*a) && (*a != '/') && (a > codebase))
         a--;
       if (*a == '/')
diff --git a/src/htslib.c b/src/htslib.c
index c8608ea9..7c9d257c 100644
--- a/src/htslib.c
+++ b/src/htslib.c
@@ -1322,7 +1322,7 @@ void treathead(t_cookie * cookie, const char *adr, const char *fil, htsblk * ret
       p++;                      // sauter espaces
     if ((int) strlen(rcvd + p) < 250) { // pas trop long?
       char tmp[256];
-      char *a = NULL, *b = NULL;
+      char *a = NULL;
 
       strcpybuff(tmp, rcvd + p);
       a = strstr(tmp, "filename=");
@@ -1335,15 +1335,9 @@ void treathead(t_cookie * cookie, const char *adr, const char *fil, htsblk * ret
 
           while((c = strchr(a, '/')))   /* skip all / (see RFC2616) */
             a = c + 1;
-          b = a + strlen(a) - 1;
-          while(is_space(*b))
-            b--;
-          b++;
-          if (b) {
-            *b = '\0';
-            if ((int) strlen(a) < 200) {        // pas trop long?
-              strcpybuff(retour->cdispo, a);
-            }
+          hts_rtrim(a, HTS_SPACES);
+          if ((int) strlen(a) < 200) { // pas trop long?
+            strcpybuff(retour->cdispo, a);
           }
         }
       }
@@ -3315,8 +3309,9 @@ int ishtml(httrackp * opt, const char *fil) {
   }
 
   /* Search for known ext */
-  for(a = fil_noquery + strlen(fil_noquery) - 1;
-      *a != '.' && *a != '/' && a > fil_noquery; a--) ;
+  for (a = hts_lastcharptr(fil_noquery);
+       *a != '.' && *a != '/' && a > fil_noquery; a--)
+    ;
   if (*a == '.') {              // a une extension
     char BIGSTK fil_noquery[HTS_URLMAXSIZE * 2];
     char *b;
@@ -4253,9 +4248,8 @@ HTSEXT_API hts_boolean get_httptype_sized(httrackp *opt, char *s, size_t ssize,
     return 1;
   } else {
     /* Check html -> text/html */
-    const char *a = fil + strlen(fil) - 1;
+    const char *a = hts_lastcharptr(fil);
 
-    /* a < fil when fil is empty: bound before dereferencing */
     while ((a > fil) && (*a != '.') && (*a != '/'))
       a--;
     if (a >= fil && *a == '.' && strlen(a) < 32) {
diff --git a/src/htsname.c b/src/htsname.c
index 031578eb..a93817cc 100644
--- a/src/htsname.c
+++ b/src/htsname.c
@@ -823,7 +823,7 @@ int url_savename(lien_adrfilsave *const afs,
   // Change the extension? e.g. php3 saved as html, cgi as html or gif/xbm
   // depending on the resolved type.
   if (ext_chg && !opt->no_type_change) {
-    char *a = fil + strlen(fil) - 1;
+    char *a = hts_lastcharptr(fil);
 
     if ((opt->debug > 1) && (opt->log != NULL)) {
       if (ext_chg == 1)
@@ -858,7 +858,7 @@ int url_savename(lien_adrfilsave *const afs,
   }
   // Rechercher premier / et dernier .
   {
-    const char *a = fil + strlen(fil) - 1;
+    const char *a = hts_lastcharptr(fil);
 
     // passer structures
     start_pos = fil;
@@ -1203,29 +1203,29 @@ int url_savename(lien_adrfilsave *const afs,
     switch (opt->savename_type % 100) {
     case 4:
     case 5:{                   // séparer par types
-        const char *a = fil + strlen(fil) - 1;
+      const char *a = hts_lastcharptr(fil);
 
-        // passer structures
-        while((a > fil) && (*a != '/') && (*a != '\\'))
-          a--;
-        if ((*a == '/') || (*a == '\\'))
-          a++;
+      // passer structures
+      while ((a > fil) && (*a != '/') && (*a != '\\'))
+        a--;
+      if ((*a == '/') || (*a == '\\'))
+        a++;
 
-        // html?
-        if ((ext_chg != 0) ? (ishtml_ext(ext) == 1) : (ishtml(opt, fil) == 1)) {
-          if (opt->savename_type % 100 == 5)
-            strcatbuff(afs->save, "html/");
-        } else {
-          const char *a = fil + strlen(fil) - 1;
+      // html?
+      if ((ext_chg != 0) ? (ishtml_ext(ext) == 1) : (ishtml(opt, fil) == 1)) {
+        if (opt->savename_type % 100 == 5)
+          strcatbuff(afs->save, "html/");
+      } else {
+        const char *a = hts_lastcharptr(fil);
 
-          while((a > fil) && (*a != '/') && (*a != '.'))
-            a--;
-          if (*a != '.')
-            strcatbuff(afs->save, "other");
-          else
-            strcatbuff(afs->save, a + 1);
-          strcatbuff(afs->save, "/");
-        }
+        while ((a > fil) && (*a != '/') && (*a != '.'))
+          a--;
+        if (*a != '.')
+          strcatbuff(afs->save, "other");
+        else
+          strcatbuff(afs->save, a + 1);
+        strcatbuff(afs->save, "/");
+      }
         /*strcatbuff(save,a); */
         /* add name */
         ADD_STANDARD_NAME(0);
@@ -1258,7 +1258,7 @@ int url_savename(lien_adrfilsave *const afs,
         }
         afs->save[i + j] = '\0';
         // ajouter extension
-        a = fil + strlen(fil) - 1;
+        a = hts_lastcharptr(fil);
         while((a > fil) && (*a != '/') && (*a != '.'))
           a--;
         if (*a == '.') {
@@ -1303,7 +1303,7 @@ int url_savename(lien_adrfilsave *const afs,
   // cela évite les /chez/toto et les /chez/toto/index.html incompatibles
   if (opt->savename_type != -1 &&
       opt->savename_delayed != HTS_SAVENAME_DELAYED_HARD) {
-    char *a = afs->save + strlen(afs->save) - 1;
+    char *a = hts_lastcharptr(afs->save);
 
     while((a > afs->save) && (*a != '.') && (*a != '/'))
       a--;
@@ -1420,8 +1420,10 @@ int url_savename(lien_adrfilsave *const afs,
   if (opt->savename_83 > 0) {
     char *a, *last;
 
-    for(last = afs->save + strlen(afs->save) - 1;
-        last != afs->save && *last != '/' && *last != '\\' && *last != '.'; last--) ;
+    for (last = hts_lastcharptr(afs->save);
+         last != afs->save && *last != '/' && *last != '\\' && *last != '.';
+         last--)
+      ;
     if (*last != '.') {
       last = NULL;
     }
@@ -1676,8 +1678,8 @@ int url_savename(lien_adrfilsave *const afs,
 #endif
         } else {                // utilisé par un AUTRE, changer de nom
           char BIGSTK tempo[HTS_URLMAXSIZE * 2];
-          char *a = afs->save + strlen(afs->save) - 1;
-          char *b;
+          char *a = hts_lastcharptr(afs->save);
+          size_t stem;
           int n = 2;
           char collisionSeparator =
               ((opt->savename_83 != HTS_SAVENAME_83_ISO9660) ? '-' : '_');
@@ -1699,18 +1701,16 @@ int url_savename(lien_adrfilsave *const afs,
             strcatbuff(tempo, afs->save);
 
           // tester la présence d'un -xx (ex: index-2.html -> index-3.html)
-          b = tempo + strlen(tempo) - 1;
-          while(isdigit((unsigned char) *b))
-            b--;
-          if (*b == collisionSeparator) {
-            sscanf(b + 1, "%d", &n);
-            *b = '\0';          // couper
+          stem = hts_rtrimlen(tempo, "0123456789");
+          if (stem != 0 && tempo[stem - 1] == collisionSeparator) {
+            sscanf(tempo + stem, "%d", &n);
+            tempo[stem - 1] = '\0'; // couper
             n++;                // plus un
           }
           // en plus il faut gérer le 8-3 .. pas facile le client
           if (opt->savename_83) {
             int max;
-            char *a = tempo + strlen(tempo) - 1;
+            char *a = hts_lastcharptr(tempo);
 
             while((a > tempo) && (*a != '/'))
               a--;
diff --git a/src/htsparse.c b/src/htsparse.c
index d81f27a3..dcf151e5 100644
--- a/src/htsparse.c
+++ b/src/htsparse.c
@@ -2057,7 +2057,7 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
                   }
                   q = strchr(a, '?');   // ne pas traiter après '?'
                   if (!q)
-                    q = a + strlen(a) - 1;
+                    q = hts_lastcharptr(a);
                   while((p = strstr(a, "//")) && (!done)) {     // remplacer // par /
                     if (p > q) {    // après le ? (toto.cgi?param=1//2.3)
                       done = 1; // stopper
@@ -2208,7 +2208,7 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
                 // OUI!!
 #if HTS_TILDE_SLASH
                 if (hts_lastchar(lien) != '/') {
-                  char *a = lien + strlen(lien) - 1;
+                  char *a = hts_lastcharptr(lien);
 
                   // éviter aussi index~1.html
                   while(a > lien && (*a != '~') && (*a != '/')
@@ -2254,7 +2254,7 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
                 // vérifier que l'on ne doit pas ajouter de .class
                 if (!error) {
                   if (add_class) {
-                    char *a = lien + strlen(lien) - 1;
+                    char *a = hts_lastcharptr(lien);
 
                     while((a > lien) && (*a != '/') && (*a != '.'))
                       a--;
@@ -2309,7 +2309,7 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
                         {
                           char *a;
 
-                          a = lien + strlen(lien) - 1;
+                          a = hts_lastcharptr(lien);
                           while((*a) && (*a != '/') && (a > lien))
                             a--;
                           if (*a == '/') {
@@ -2320,8 +2320,8 @@ int htsparse(htsmoduleStruct * str, htsmoduleStructExtended * stre) {
                     }
 
                     if (!error) {       // pas d'erreur?
-                      if (p_type == 2) {        // code ET PAS codebase      
-                        char *a = lien + strlen(lien) - 1;
+                      if (p_type == 2) { // code ET PAS codebase
+                        char *a = hts_lastcharptr(lien);
                         char *start_of_filename = jump_identification(lien);
 
                         if (start_of_filename != NULL
diff --git a/src/htssafe.h b/src/htssafe.h
index da45ebd5..f2ad92d5 100644
--- a/src/htssafe.h
+++ b/src/htssafe.h
@@ -569,6 +569,38 @@ static HTS_INLINE HTS_UNUSED hts_boolean hts_choplastchar(char *s) {
   return HTS_FALSE;
 }
 
+/* htslib.h's is_space() and is_realspace() as hts_rtrim() sets; -#test=rtrim
+   keeps them in sync. */
+#define HTS_SPACES " \"\n\r\t\f\v'"
+#define HTS_REALSPACES " \n\r\t\f\v"
+
+/* Length of s once its trailing bytes from set are dropped; 0 if they all are.
+   Counts down from the end, so it stops at s rather than below the buffer. */
+static HTS_INLINE HTS_UNUSED size_t hts_rtrimlen(const char *s,
+                                                 const char *set) {
+  size_t len = strlen(s);
+
+  while (len != 0 && strchr(set, s[len - 1]) != NULL)
+    len--;
+  return len;
+}
+
+/* Drop the trailing bytes of s that occur in set. */
+static HTS_INLINE HTS_UNUSED void hts_rtrim(char *s, const char *set) {
+  s[hts_rtrimlen(s, set)] = '\0';
+}
+
+/* Offset of the last character of s, or 0 when s is empty. */
+static HTS_INLINE HTS_UNUSED size_t hts_lastcharoffset(const char *s) {
+  const size_t len = strlen(s);
+
+  return len != 0 ? len - 1 : 0;
+}
+
+/* Address of the last character of S, or of its terminating NUL when S is
+   empty. S is evaluated twice, so pass an lvalue. */
+#define hts_lastcharptr(S) ((S) + hts_lastcharoffset(S))
+
 /* Thin aliases over the libc allocator/memcpy (historical "t" suffix); no
    added bounds checking. freet() also NULLs the freed pointer and tolerates
    NULL. memcpybuff() despite the name is a raw memcpy: the caller owns the
diff --git a/src/htstools.c b/src/htstools.c
index b13c166a..11d65496 100644
--- a/src/htstools.c
+++ b/src/htstools.c
@@ -324,8 +324,7 @@ int lienrelatif(char *s, size_t ssize, const char *link, const char *curr_fil) {
   curr = _curr;
   strlcpybuff(curr, curr_fil, sizeof(_curr));
   if ((a = strchr(curr, '?')) == NULL) { // cut at the ? (query parameters)
-    // an empty path has no last character: curr-1 would read before the buffer
-    a = curr[0] != '\0' ? curr + strlen(curr) - 1 : curr;
+    a = hts_lastcharptr(curr);
   }
   while((*a != '/') && (a > curr))
     a--;                        // chercher dernier / du chemin courant
diff --git a/src/proxy/store.c b/src/proxy/store.c
index 18f6b477..f04a0374 100644
--- a/src/proxy/store.c
+++ b/src/proxy/store.c
@@ -903,10 +903,11 @@ int PT_LoadCache__New(PT_Index index_, const char *filename) {
       coucal hashtable = index->hash;
 
       /* Compute base path for this index - the filename MUST be absolute! */
-      for(slashes = 2, abpath = filename + (int) strlen(filename) - 1;
-          abpath > filename && ((*abpath != '/' && *abpath != '\\')
-                                || --slashes > 0);
-          abpath--) ;
+      for (slashes = 2, abpath = hts_lastcharptr(filename);
+           abpath > filename &&
+           ((*abpath != '/' && *abpath != '\\') || --slashes > 0);
+           abpath--)
+        ;
       index->path[0] = '\0';
       if (slashes == 0 && *abpath != 0) {
         int i;
@@ -1498,10 +1499,11 @@ static int PT_LoadCache__Old(PT_Index index_, const char *filename) {
 
       /* -------------------- COPY OF THE __New() CODE -------------------- */
       /* Compute base path for this index - the filename MUST be absolute! */
-      for(slashes = 2, abpath = filename + (int) strlen(filename) - 1;
-          abpath > filename && ((*abpath != '/' && *abpath != '\\')
-                                || --slashes > 0);
-          abpath--) ;
+      for (slashes = 2, abpath = hts_lastcharptr(filename);
+           abpath > filename &&
+           ((*abpath != '/' && *abpath != '\\') || --slashes > 0);
+           abpath--)
+        ;
       index->path[0] = '\0';
       if (slashes == 0 && *abpath != 0) {
         int i;


Test patch:
diff --git a/src/htsselftest.c b/src/htsselftest.c
index 8d809a39..0a9c59b5 100644
--- a/src/htsselftest.c
+++ b/src/htsselftest.c
@@ -6973,9 +6973,10 @@ static int st_changes_race(httrackp *opt, int argc, char **argv) {
   return err;
 }
 
-/* The x[strlen(x) - 1] class (#770). The string starts mid-arena so the byte
-   it must not touch is a real neighbour; poisoned with '#', not 0, or a stray
-   NUL terminator would read as untouched. */
+/* The x[strlen(x) - 1] class (#770) and its pointer spelling x + strlen(x) - 1
+   (#781). The string starts mid-arena so the byte it must not touch is a real
+   neighbour; poisoned with '#', not 0, or a stray NUL terminator would read as
+   untouched. */
 static int st_lastchar(httrackp *opt, int argc, char **argv) {
   enum { off = 8 };
 
@@ -7042,11 +7043,52 @@ static int st_lastchar(httrackp *opt, int argc, char **argv) {
   CHECK(s[0] == '\0');
   CHECK(arena[guard] == '#');
 
+  /* the pointer spelling (#781): on an empty string the address must be the
+     terminating NUL, never the byte before it */
+  REPOISON("");
+  CHECK(hts_lastcharoffset(s) == 0);
+  CHECK(hts_lastcharptr(s) == s);
+  CHECK(*hts_lastcharptr(s) == '\0');
+  *hts_lastcharptr(s) = 'Z'; /* a write through it must stay inside s */
+  CHECK(arena[guard] == '#');
+  CHECK(s[0] == 'Z');
+
+  /* the neighbour must not be mistaken for the string's own last byte */
+  REPOISON("");
+  arena[guard] = '/';
+  CHECK(hts_lastcharptr(s) == s);
+  CHECK(*hts_lastcharptr(s) != '/');
+  CHECK(arena[guard] == '/');
+
+  /* the walk-back loops the sites use must stop at once on an empty string */
+  REPOISON("");
+  {
+    const char *p = hts_lastcharptr(s);
+    int steps = 0;
+
+    while (p > s && *p != '/')
+      p--, steps++;
+    CHECK(steps == 0);
+    CHECK(p == s);
+  }
+
+  REPOISON("ab/");
+  CHECK(hts_lastcharoffset(s) == 2);
+  CHECK(hts_lastcharptr(s) == s + 2);
+  CHECK(*hts_lastcharptr(s) == '/');
+  REPOISON("/");
+  CHECK(hts_lastcharptr(s) == s);
+  CHECK(*hts_lastcharptr(s) == '/');
+  CHECK(arena[guard] == '#');
+
   /* control: the canary must be able to fail, or the checks above prove
      nothing. Clobber it exactly as the unguarded idiom would. */
   REPOISON("");
   s[-1] = '\0';
   CHECK(arena[guard] != '#');
+  REPOISON("");
+  *(s + strlen(s) - 1) = 'X';
+  CHECK(arena[guard] != '#');
 
 #undef REPOISON
 #undef CHECK
@@ -7055,6 +7097,91 @@ static int st_lastchar(httrackp *opt, int argc, char **argv) {
   return err;
 }
 
+/* hts_rtrim() and the sets it is called with. The string starts mid-arena, and
+   the byte below it is poisoned with '#' rather than 0, or the stray NUL the
+   old loop wrote there would read as untouched. */
+static int st_rtrim(httrackp *opt, int argc, char **argv) {
+  enum { off = 8 };
+
+  char arena[24];
+  char *const s = &arena[off];
+  const int guard = off - 1;
+  int err = 0;
+  int c;
+
+  (void) opt;
+  (void) argc;
+  (void) argv;
+
+#define REPOISON(str)                                                          \
+  do {                                                                         \
+    memset(arena, '#', sizeof(arena));                                         \
+    strlcpybuff(s, (str), sizeof(arena) - off);                                \
+  } while (0)
+#define CHECK(cond)                                                            \
+  do {                                                                         \
+    if (!(cond)) {                                                             \
+      printf("  FAIL line %d: %s\n", __LINE__, #cond);                         \
+      err = 1;                                                                 \
+    }                                                                          \
+  } while (0)
+
+  /* nothing but spaces: the case that ran the old loop off the front */
+  REPOISON("   ");
+  hts_rtrim(s, HTS_REALSPACES);
+  CHECK(s[0] == '\0');
+  CHECK(arena[guard] == '#');
+
+  /* a space sitting below the string must not be eaten as if it were part of
+     it, which is exactly what the old loop did */
+  REPOISON("   ");
+  arena[guard] = ' ';
+  hts_rtrim(s, HTS_REALSPACES);
+  CHECK(s[0] == '\0');
+  CHECK(arena[guard] == ' ');
+
+  REPOISON("");
+  hts_rtrim(s, HTS_REALSPACES);
+  CHECK(s[0] == '\0');
+  CHECK(arena[guard] == '#');
+
+  REPOISON("a b \t\r\n");
+  hts_rtrim(s, HTS_REALSPACES);
+  CHECK(strcmp(s, "a b") == 0);
+  CHECK(arena[guard] == '#');
+
+  REPOISON("ab");
+  hts_rtrim(s, HTS_REALSPACES);
+  CHECK(strcmp(s, "ab") == 0);
+
+  /* quotes count as space for is_space() but not for is_realspace() */
+  REPOISON("v\" ");
+  hts_rtrim(s, HTS_REALSPACES);
+  CHECK(strcmp(s, "v\"") == 0);
+  REPOISON("v\" ");
+  hts_rtrim(s, HTS_SPACES);
+  CHECK(strcmp(s, "v") == 0);
+
+  /* the sets must stay the macros they stand for */
+  for (c = 1; c < 256; c++) {
+    const char b = (char) c;
+
+    CHECK((strchr(HTS_SPACES, b) != NULL) == (is_space(b) != 0));
+    CHECK((strchr(HTS_REALSPACES, b) != NULL) == (is_realspace(b) != 0));
+  }
+
+  /* control: the canary must be able to fail */
+  REPOISON("   ");
+  s[-1] = '\0';
+  CHECK(arena[guard] != '#');
+
+#undef REPOISON
+#undef CHECK
+
+  printf("rtrim self-test: %s\n", err ? "FAIL" : "OK");
+  return err;
+}
+
 /* ------------------------------------------------------------ */
 /* Registry: name -> handler, with a usage hint and a one-line description. */
 /* ------------------------------------------------------------ */
@@ -7110,8 +7237,10 @@ static const struct selftest_entry {
     {"strsafe", "[overflow|overflow-buff|overflow-src [str]]",
      "bounded string-op self-test", st_strsafe},
     {"copyopt", "", "copy_htsopt option-copy self-test", st_copyopt},
-    {"lastchar", "", "last-char helpers never index before the buffer (#770)",
+    {"lastchar", "",
+     "last-char helpers never index before the buffer (#770, #781)",
      st_lastchar},
+    {"rtrim", "", "hts_rtrim never walks below the buffer", st_rtrim},
     {"changes", "", "--changes bucket accounting and JSON escaping (#714)",
      st_changes},
     {"changes-race", "<dir>", "--changes under a late transfer thread (#714)",
diff --git a/tests/01_engine-lastchar.test b/tests/01_engine-lastchar.test
index 8b3b59f9..39c90102 100644
--- a/tests/01_engine-lastchar.test
+++ b/tests/01_engine-lastchar.test
@@ -1,8 +1,8 @@
 #!/bin/bash
 #
-# Runs the lastchar self-test (#770), then keeps the idiom it replaced from
-# coming back: x[strlen(x) - 1] indexes one byte before the buffer when x is
-# empty, and the guard is never where the index is.
+# Runs the lastchar self-test (#770, #781), then keeps the idiom it replaced
+# from coming back: x[strlen(x) - 1] and x + strlen(x) - 1 both land one byte
+# before the buffer when x is empty, and the guard is never where they are.
 
 set -eu
 
@@ -33,3 +33,36 @@ test -z "$hits" || {
     echo "$hits" >&2
     exit 1
 }
+
+# Same for the pointer spelling. The three survivors sit in commented-out
+# blocks in htsname.c; the rest are this class being named, plus the self-test's
+# own deliberate underflow.
+hits=$(command grep -rnE '\+ *\(?(int|size_t)?\)? *strlen *\([^)]*\) *- *1' \
+    "$top_srcdir/src" --include='*.c' --include='*.h' --exclude-dir=coucal |
+    grep -v 'htsname\.c:.*char\* a=\(fil+strlen(fil)\|save+strlen(save)\)-1;' |
+    grep -v 'htsselftest\.c:.*pointer spelling x + strlen(x) - 1' |
+    grep -v 'htsselftest\.c:.*\*(s + strlen(s) - 1) =' || true)
+
+test -z "$hits" || {
+    echo "x + strlen(x) - 1 reintroduced; use hts_lastcharptr:" >&2
+    echo "$hits" >&2
+    exit 1
+}
+
+# url_savename() has nine of these sites and reaches all of them with an empty
+# "fil", which "?x=1" produces. Names are asserted loosely, since the point is
+# the run: on unfixed code the sanitized CI leg aborts inside htsname.c.
+for args in "?x=1 text/plain" "?x=1 text/html" "?x=1 text/html type=4"; do
+    # shellcheck disable=SC2086
+    name=$(httrack -#test=savename $args) || {
+        echo "httrack -#test=savename $args exited non-zero: $name" >&2
+        exit 1
+    }
+    case "$name" in
+    "savename: "?*) ;;
+    *)
+        echo "-#test=savename $args produced no name: $name" >&2
+        exit 1
+        ;;
+    esac
+done
diff --git a/tests/116_engine-rtrim.test b/tests/116_engine-rtrim.test
new file mode 100755
index 00000000..e7df8bf6
--- /dev/null
+++ b/tests/116_engine-rtrim.test
@@ -0,0 +1,107 @@
+#!/bin/bash
+#
+# Backward scans that start at s + strlen(s) - 1 and decrement with no lower
+# bound: the config-file right trim and the collision-rename digit scan both
+# walked off the front of a stack buffer. Neither ASan nor _FORTIFY_SOURCE sees
+# the self-test's overrun (it lands in the same frame), hence the poisoned byte.
+
+set -euo pipefail
+
+: "${top_srcdir:=..}"
+
+# Resolve httrack before any cd: PATH may carry a build-relative entry.
+bin=$(command -v httrack) || {
+    echo "FAIL: httrack not found on PATH" >&2
+    exit 1
+}
+case "$bin" in
+/*) ;;
+*) bin="$(cd "$(dirname "$bin")" && pwd)/$(basename "$bin")" ;;
+esac
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/httrack_rtrim.XXXXXX") || exit 1
+trap 'set +e; rm -rf "$tmp"' EXIT
+trap 'rm -rf "$tmp"' HUP INT QUIT PIPE TERM
+
+fail() {
+    echo "FAIL: $1" >&2
+    exit 1
+}
+
+# --- 1. the helper itself ---------------------------------------------------
+out=$("$bin" -#test=rtrim) || fail "httrack -#test=rtrim exited non-zero: $out"
+test "$out" = "rtrim self-test: OK" || fail "expected 'rtrim self-test: OK', got: $out"
+
+# --- 2. collision rename over an all-digit name -----------------------------
+# Two pages with the same all-digit name save to 2024.html, so the second goes
+# through the -<n> rename and the digit scan has nothing but digits to walk
+# over. Driven twice, because the two routes differ in who picks the URLs.
+site="$tmp/site"
+mkdir -p "$site/a" "$site/b"
+printf '%s\n' '<html><body><a href="a/2024.html">a</a> <a href="b/2024.html">b</a></body></html>' \
+    >"$site/index.html"
+echo '<html><body>A</body></html>' >"$site/a/2024.html"
+echo '<html><body>B</body></html>' >"$site/b/2024.html"
+
+# -g flattens the tree, and pins depth to 0: the operator supplies both URLs.
+out1="$tmp/out1"
+mkdir -p "$out1"
+(cd "$out1" && "$bin" -g "file://$site/a/2024.html" "file://$site/b/2024.html" \
+    --quiet -n >.log 2>&1) || fail "collision crawl exited non-zero: $(tail -3 "$out1/.log")"
+
+test -f "$out1/2024.html" || fail "2024.html not mirrored"
+test -f "$out1/2024-2.html" || fail "colliding name was not renamed to 2024-2.html"
+
+# -N "%n.%t" collides the same way with depth unrestricted, so one starting URL
+# is enough and the crawled page picks both colliding names itself.
+out3="$tmp/out3"
+mkdir -p "$out3"
+(cd "$out3" && "$bin" "file://$site/index.html" -N "%n.%t" --quiet -n >.log 2>&1) ||
+    fail "userdef-savename crawl exited non-zero: $(tail -3 "$out3/.log")"
+test -f "$out3/2024.html" || fail "2024.html not mirrored under -N '%n.%t'"
+test -f "$out3/2024-2.html" ||
+    fail "colliding name was not renamed under -N '%n.%t' (links followed, not argv)"
+
+# The rename must keep counting, not restart: a third collision is -3.
+mkdir -p "$site/c"
+echo '<html><body>C</body></html>' >"$site/c/2024.html"
+out2="$tmp/out2"
+mkdir -p "$out2"
+(cd "$out2" && "$bin" -g "file://$site/a/2024.html" "file://$site/b/2024.html" \
+    "file://$site/c/2024.html" --quiet -n >.log 2>&1) ||
+    fail "three-way collision crawl exited non-zero: $(tail -3 "$out2/.log")"
+test -f "$out2/2024-3.html" || fail "third colliding name was not renamed to 2024-3.html"
+
+# --- 3. a blank config line is skipped, not parsed ---------------------------
+# HTS_HTTRACKRC is ".httrackrc" on POSIX and "httrackrc" on Windows.
+rcdir="$tmp/rc"
+mkdir -p "$rcdir"
+echo '<html><body>hi</body></html>' >"$rcdir/index.html"
+printf '   \nzzz-not-an-option\n' >"$rcdir/.httrackrc"
+cp "$rcdir/.httrackrc" "$rcdir/httrackrc"
+
+rc=0
+log=$( (cd "$rcdir" && "$bin" "file://$rcdir/index.html" --quiet -n 2>&1)) || rc=$?
+test "$rc" -eq 0 || fail "rc-file crawl exited $rc: $log"
+log=${log//$'\r'/} # the empty-option check below must not miss a CRLF line
+
+# The line after the blank one must still be read, or this proves nothing.
+case "$log" in
+*"Unknown option: zzz-not-an-option"*) ;;
+*) fail "config file was not parsed past the blank line: $log" ;;
+esac
+# The blank line itself must not become an empty option.
+case "$log" in
+*"Unknown option: "$'\n'*) fail "blank config line was parsed as an empty option: $log" ;;
+esac
+
+# --- 4. the call sites stay bounded -----------------------------------------
+# Sections 2 and 3 only go red under a sanitizer: on a plain build the byte
+# below the buffer is usually not a digit, so a reverted scan stops anyway and
+# produces the same names. Pin the two sites at the source instead.
+command grep -q 'hts_rtrim(line, HTS_REALSPACES)' "$top_srcdir/src/htsalias.c" ||
+    fail "htsalias.c right trim no longer goes through hts_rtrim"
+command grep -q 'hts_rtrimlen(tempo, "0123456789")' "$top_srcdir/src/htsname.c" ||
+    fail "htsname.c collision-suffix scan no longer goes through hts_rtrimlen"
+
+exit 0
diff --git a/tests/tests-list.mk b/tests/tests-list.mk
index 134d47f7..d15d5fba 100644
--- a/tests/tests-list.mk
+++ b/tests/tests-list.mk
@@ -97,6 +97,7 @@ TESTS += 110_local-ftp-parallel.test
 TESTS += 111_local-ftp-update-rest.test
 TESTS += 112_local-single-file-fragment.test
 TESTS += 114_local-update-304-leak.test
+TESTS += 116_engine-rtrim.test
 TESTS += 117_local-proxytrack-ndx-cursor.test
 TESTS += 121_local-ftp-nocache-splice.test
 TESTS += 11_crawl-cookies.test