Repository: xroche/httrack (2026-07-27T19:25:51Z)

Issue:
Four time helpers still read libc's shared gmtime/localtime static
#794 moved the four `gmtime()` sites that copied out of the CRT static onto a reentrant `hts_gmtime()` helper. Several callers still hold a pointer into libc's static across other work, and glibc's `gmtime`/`localtime` buffer is process-wide rather than per-thread, so on POSIX the window is wider than the Windows-only one #794 closed.

`time_gmt_rfc822()` and `get_filetime_rfc822()` (src/htslib.c) call `gmtime()`, fall back to `localtime()` when it fails, then pass the pointer on to `time_rfc822()` for the `strftime()`. `get_filetime_rfc822()` is reached from the transfer path (htsback.c:2157). `time_local_rfc822()`, `htsback.c:949` and `htscore.c:3154` do the same with `localtime()` for the progress and log timestamps. `proxy/store.c:2025` dereferences `localtime()`'s result inline on the BSD arm, without a NULL check.

The gmtime half is a one-line change each now that the helper exists. The localtime half wants an `hts_localtime()` beside it. The gmtime-then-localtime fallback in the two rfc822 functions deserves its own look: falling back to local time and then labelling the output GMT is wrong whichever thread wins.

Noticed while fixing #794.



Discussion:

Patch:
diff --git a/src/htsback.c b/src/htsback.c
index c7460c8b..df8591cb 100644
--- a/src/htsback.c
+++ b/src/htsback.c
@@ -993,16 +993,15 @@ int back_finalize(httrackp * opt, cache_back * cache, struct_back * sback,
           char flags[32];
           char s[256];
           time_t tt;
-          struct tm *A;
+          struct tm tmv;
 
           tt = time(NULL);
-          A = localtime(&tt);
-          if (A == NULL) {
+          if (!hts_localtime(tt, &tmv)) {
             int localtime_returned_null = 0;
 
             assertf(localtime_returned_null);
           }
-          strftime(s, 250, "%H:%M:%S", A);
+          strftime(s, 250, "%H:%M:%S", &tmv);
 
           flags[0] = '\0';
           /* input flags */
diff --git a/src/htscore.c b/src/htscore.c
index 1e30074d..c1464f5c 100644
--- a/src/htscore.c
+++ b/src/htscore.c
@@ -1045,13 +1045,14 @@ int httpmirror(char *url1, httrackp * opt) {
     {
       TStamp tl = 0;
       time_t tt;
-      struct tm *A;
+      struct tm tmv;
 
       tt = time(NULL);
-      A = localtime(&tt);
-      tl += A->tm_sec;
-      tl += A->tm_min * 60;
-      tl += A->tm_hour * 60 * 60;
+      if (hts_localtime(tt, &tmv)) {
+        tl += tmv.tm_sec;
+        tl += tmv.tm_min * 60;
+        tl += tmv.tm_hour * 60 * 60;
+      }
       if (tl > opt->waittime)   // attendre minuit
         rollover = 1;
     }
@@ -1061,13 +1062,14 @@ int httpmirror(char *url1, httrackp * opt) {
     do {
       TStamp tl = 0;
       time_t tt;
-      struct tm *A;
+      struct tm tmv;
 
       tt = time(NULL);
-      A = localtime(&tt);
-      tl += A->tm_sec;
-      tl += A->tm_min * 60;
-      tl += A->tm_hour * 60 * 60;
+      if (hts_localtime(tt, &tmv)) {
+        tl += tmv.tm_sec;
+        tl += tmv.tm_min * 60;
+        tl += tmv.tm_hour * 60 * 60;
+      }
 
       if (rollover) {
         if (tl <= opt->waittime)
@@ -3148,16 +3150,15 @@ int fspc(httrackp * opt, FILE * fp, const char *type) {
   if (fp != NULL) {
     char s[256];
     time_t tt;
-    struct tm *A;
+    struct tm tmv;
 
     tt = time(NULL);
-    A = localtime(&tt);
-    if (A == NULL) {
+    if (!hts_localtime(tt, &tmv)) {
       int localtime_returned_null = 0;
 
       assertf(localtime_returned_null);
     }
-    strftime(s, 250, "%H:%M:%S", A);
+    strftime(s, 250, "%H:%M:%S", &tmv);
     if (strnotempty(type))
       fprintf(fp, "%s\t%c%s: \t", s, hichar(*type), type + 1);
     else
diff --git a/src/htslib.c b/src/htslib.c
index 7c9d257c..0b4191b1 100644
--- a/src/htslib.c
+++ b/src/htslib.c
@@ -2684,13 +2684,15 @@ HTSEXT_API void qsec2str(char *st, TStamp t) {
 // heure actuelle, GMT, format rfc (taille buffer 256o)
 void time_gmt_rfc822(char *s) {
   time_t tt;
-  struct tm *A;
+  struct tm tmv;
 
   tt = time(NULL);
-  A = gmtime(&tt);
-  if (A == NULL)
-    A = localtime(&tt);
-  time_rfc822(s, A);
+  /* no local-time fallback: it would format local time and still label it GMT
+   * (#806) */
+  if (hts_gmtime(tt, &tmv))
+    time_rfc822(s, &tmv);
+  else
+    s[0] = '\0';
 }
 
 void hts_now_iso8601(char out[32]) {
@@ -2705,11 +2707,13 @@ void hts_now_iso8601(char out[32]) {
 // heure actuelle, format rfc (taille buffer 256o)
 void time_local_rfc822(char *s) {
   time_t tt;
-  struct tm *A;
+  struct tm tmv;
 
   tt = time(NULL);
-  A = localtime(&tt);
-  time_rfc822_local(s, A);
+  if (hts_localtime(tt, &tmv))
+    time_rfc822_local(s, &tmv);
+  else
+    s[0] = '\0';
 }
 
 /* convertir une chaine en temps */
@@ -2857,14 +2861,13 @@ int get_filetime_rfc822(const char *file, char *date) {
 
   date[0] = '\0';
   if (STAT(file, &buf) == 0) {
-    struct tm *A;
+    struct tm tmv;
     time_t tt = buf.st_mtime;
 
-    A = gmtime(&tt);
-    if (A == NULL)
-      A = localtime(&tt);
-    if (A != NULL) {
-      time_rfc822(date, A);
+    /* no local-time fallback: it would format local time and still label it GMT
+     * (#806) */
+    if (hts_gmtime(tt, &tmv)) {
+      time_rfc822(date, &tmv);
       return 1;
     }
   }
diff --git a/src/htslib.h b/src/htslib.h
index 62d00d16..cc778b25 100644
--- a/src/htslib.h
+++ b/src/htslib.h
@@ -170,6 +170,17 @@ static HTS_INLINE HTS_UNUSED hts_boolean hts_gmtime(time_t t,
 #endif
 }
 
+/* Break t down as local time into the caller's buffer, HTS_FALSE if that
+   failed. localtime()'s static is shared, same rationale as hts_gmtime(). */
+static HTS_INLINE HTS_UNUSED hts_boolean hts_localtime(time_t t,
+                                                       struct tm *tmbuf) {
+#ifdef _WIN32
+  return localtime_s(tmbuf, &t) == 0 ? HTS_TRUE : HTS_FALSE;
+#else
+  return localtime_r(&t, tmbuf) != NULL ? HTS_TRUE : HTS_FALSE;
+#endif
+}
+
 /* Library internal definictions */
 #ifdef HTS_INTERNAL_BYTECODE
 
diff --git a/src/proxy/store.c b/src/proxy/store.c
index a4124410..60ea3f7c 100644
--- a/src/proxy/store.c
+++ b/src/proxy/store.c
@@ -2014,7 +2014,12 @@ static time_t getGMT(struct tm *tm) {   /* hey, time_t is local! */
     /* BSD does not have static "timezone" declared */
 #if (defined(BSD) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) || defined(__FreeBSD_kernel__))
     time_t now = time(NULL);
-    time_t timezone = -localtime(&now)->tm_gmtoff;
+    struct tm nowtm;
+    time_t timezone;
+
+    if (!hts_localtime(now, &nowtm))
+      return (time_t) -1;
+    timezone = -nowtm.tm_gmtoff;
 #elif defined(_MSC_VER)
     /* MSVC spells it _timezone */
     const time_t timezone = _timezone;


Test patch:
diff --git a/src/htsselftest.c b/src/htsselftest.c
index 4df937a1..a06bbf7a 100644
--- a/src/htsselftest.c
+++ b/src/htsselftest.c
@@ -6852,6 +6852,149 @@ static int st_gmtime(httrackp *opt, int argc, char **argv) {
   return err;
 }
 
+/* #806: hts_localtime() must own its output too, same rationale as
+   hts_gmtime() (#794). Reference table computed under TZ=XXX5 (fixed
+   UTC-5, no DST), which the driving .test script sets. */
+#define LOCALTIME_THREADS 8
+#define LOCALTIME_ROUNDS 50000
+
+static const struct {
+  time_t t;
+  int year, mon, mday, hour, min, sec, wday, yday;
+} localtime_refs[] = {
+    {(time_t) 0, 69, 11, 31, 19, 0, 0, 3, 364},
+    {(time_t) 951782400, 100, 1, 28, 19, 0, 0, 1,
+     58}, /* a leap day, GMT side */
+    {(time_t) 1000000000, 101, 8, 8, 20, 46, 40, 6, 250},
+    {(time_t) 2147483647, 138, 0, 18, 22, 14, 7, 1, 17},
+};
+
+#define LOCALTIME_REFS                                                         \
+  ((int) (sizeof(localtime_refs) / sizeof(localtime_refs[0])))
+
+static hts_boolean localtime_ref_matches(int i, const struct tm *tm) {
+  if (tm->tm_year != localtime_refs[i].year ||
+      tm->tm_mon != localtime_refs[i].mon ||
+      tm->tm_mday != localtime_refs[i].mday ||
+      tm->tm_hour != localtime_refs[i].hour ||
+      tm->tm_min != localtime_refs[i].min ||
+      tm->tm_sec != localtime_refs[i].sec ||
+      tm->tm_wday != localtime_refs[i].wday ||
+      tm->tm_yday != localtime_refs[i].yday)
+    return HTS_FALSE;
+  return HTS_TRUE;
+}
+
+static htsmutex localtime_lock = HTSMUTEX_INIT;
+static int localtime_bad = 0;
+
+static void localtime_thread(void *arg) {
+  const int i = *(const int *) arg;
+  int bad = 0, round;
+
+  for (round = 0; round < LOCALTIME_ROUNDS; round++) {
+    struct tm tmv;
+
+    if (!hts_localtime(localtime_refs[i].t, &tmv) ||
+        !localtime_ref_matches(i, &tmv))
+      bad++;
+  }
+  hts_mutexlock(&localtime_lock);
+  localtime_bad += bad;
+  hts_mutexrelease(&localtime_lock);
+}
+
+static int st_localtime(httrackp *opt, int argc, char **argv) {
+  static int idx[LOCALTIME_THREADS];
+  int err = 0, i;
+
+  (void) opt;
+
+  if (argc < 1) {
+    fprintf(stderr, "usage: -#test=localtime <writable directory>\n");
+    return 1;
+  }
+
+  for (i = 0; i < LOCALTIME_REFS; i++) {
+    struct tm tmv;
+
+    if (!hts_localtime(localtime_refs[i].t, &tmv)) {
+      fprintf(stderr, "localtime: conversion #%d failed\n", i);
+      err = 1;
+    } else if (!localtime_ref_matches(i, &tmv)) {
+      fprintf(stderr,
+              "localtime: #%d gave %04d-%02d-%02d %02d:%02d:%02d (wday %d, "
+              "yday %d)\n",
+              i, tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday, tmv.tm_hour,
+              tmv.tm_min, tmv.tm_sec, tmv.tm_wday, tmv.tm_yday);
+      err = 1;
+    }
+  }
+
+  if (sizeof(time_t) >= 8) {
+    const time_t beyond = (time_t) INT64_MAX;
+    struct tm tmv;
+
+    if (hts_localtime(beyond, &tmv)) {
+      fprintf(stderr,
+              "localtime: an out-of-range time_t was reported converted\n");
+      err = 1;
+    }
+  }
+
+  for (i = 0; i < LOCALTIME_THREADS; i++) {
+    idx[i] = i % LOCALTIME_REFS;
+    if (hts_newthread(localtime_thread, &idx[i]) != 0) {
+      fprintf(stderr, "localtime: cannot spawn\n");
+      return 1;
+    }
+  }
+  htsthread_wait();
+  if (localtime_bad != 0) {
+    fprintf(stderr, "localtime: %d/%d concurrent conversions were corrupt\n",
+            localtime_bad, LOCALTIME_THREADS * LOCALTIME_ROUNDS);
+    err = 1;
+  }
+
+  /* get_filetime_rfc822() must report GMT, never the process's local zone,
+     and never a silent fallback to it on gmtime() failure (#806). */
+  {
+    char path[HTS_URLMAXSIZE];
+    char date[256];
+    struct tm parsed;
+
+    snprintf(path, sizeof(path), "%s/filetime.bin", argv[0]);
+    structcheck(path);
+    {
+      FILE *fp = FOPEN(path, "wb");
+
+      if (fp == NULL) {
+        fprintf(stderr, "localtime: cannot write %s\n", path);
+        return 1;
+      }
+      fputc('x', fp);
+      fclose(fp);
+    }
+    if (set_filetime_rfc822(path, "Tue, 29 Feb 2000 00:00:00 GMT") != 0) {
+      fprintf(stderr, "localtime: cannot set %s's mtime\n", path);
+      err = 1;
+    } else if (!get_filetime_rfc822(path, date)) {
+      fprintf(stderr, "localtime: get_filetime_rfc822 failed on %s\n", path);
+      err = 1;
+    } else if (convert_time_rfc822(&parsed, date) == NULL ||
+               parsed.tm_year != 100 || parsed.tm_mon != 1 ||
+               parsed.tm_mday != 29 || parsed.tm_hour != 0) {
+      fprintf(stderr,
+              "localtime: get_filetime_rfc822 reported \"%s\" (TZ=%s)\n", date,
+              getenv("TZ") ? getenv("TZ") : "");
+      err = 1;
+    }
+  }
+
+  printf("localtime self-test: %s\n", err ? "FAIL" : "OK");
+  return err;
+}
+
 #define CHANGES_RACE_FILES 8
 #define CHANGES_RACE_ROUNDS 400
 
@@ -7279,6 +7422,9 @@ static const struct selftest_entry {
      st_threadwait},
     {"gmtime", "",
      "hts_gmtime() fills the caller's buffer, not a static (#794)", st_gmtime},
+    {"localtime", "<dir>",
+     "hts_localtime() and get_filetime_rfc822()'s GMT labelling (#806)",
+     st_localtime},
     {"backswap", "", "which backlog slots may be swapped to the ready table",
      st_backswap},
     {"pause", "", "randomized inter-file pause target self-test", st_pause},
diff --git a/tests/133_engine-reentrant-time.test b/tests/133_engine-reentrant-time.test
new file mode 100755
index 00000000..87d38203
--- /dev/null
+++ b/tests/133_engine-reentrant-time.test
@@ -0,0 +1,40 @@
+#!/bin/bash
+#
+
+set -euo pipefail
+
+testdir=$(cd "$(dirname "$0")" && pwd)
+# shellcheck source=tests/testlib.sh
+. "${testdir}/testlib.sh"
+: "${top_srcdir:=..}"
+
+# A non-UTC zone, so a helper that reached for gmtime instead of localtime (or
+# vice-versa) gives a different answer: CI runners are UTC, where they agree.
+TZ=XXX5
+export TZ
+LC_ALL=C
+export LC_ALL
+
+tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_localtime_st.XXXXXX") || exit 1
+trap 'set +e; rm -rf "$tmpdir"' EXIT
+trap 'rm -rf "$tmpdir"' HUP INT QUIT PIPE TERM
+
+# hts_localtime() reentrancy, and get_filetime_rfc822()'s GMT labelling (#806).
+expect_ok "localtime self-test" httrack -O "${tmpdir}/o1" \
+    -#test=localtime "${tmpdir}/data"
+
+# time_gmt_rfc822()/get_filetime_rfc822() must never format local time and
+# still label it GMT: on a gmtime() failure they must fail, not fall back to
+# (hts_)localtime() (#806). gmtime() failure is not reproducible at runtime
+# (it needs an out-of-range time_t, and neither function's input can be
+# pushed out of range from the outside), so this is a source guard against
+# the fallback creeping back in, not a dynamic probe.
+for fn in time_gmt_rfc822 get_filetime_rfc822; do
+    body=$(awk "/^[a-z ]*${fn}\\(/,/^}/" "$top_srcdir/src/htslib.c")
+    case "$body" in
+    *localtime*)
+        echo "FAIL: ${fn}() still falls back to (hts_)localtime()"
+        exit 1
+        ;;
+    esac
+done
diff --git a/tests/tests-list.mk b/tests/tests-list.mk
index ef4ef5be..6ea0b80c 100644
--- a/tests/tests-list.mk
+++ b/tests/tests-list.mk
@@ -197,3 +197,4 @@ TESTS += 98_local-warc-segments.test
 TESTS += 99_local-robots-error.test
 TESTS += 119_local-proxytrack-ndx-fields2.test
 TESTS += 118_local-proxytrack-arcwrite.test
+TESTS += 133_engine-reentrant-time.test