From 893ffa38ff3fd641077348b1418cddd0c2d19c5a Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 21 Oct 2026 14:33:58 +0200 Subject: [PATCH 1/5] Read dates after 2038 correctly on 32-bit builds timegm() returns -1 for any date past 2038-01-19T03:14:07Z when time_t is 32 bits (Debian i386, armhf before t64, Win32 with _USE_32BIT_TIME_T), so a mirrored file kept its download time, a Retry-After date was ignored, and proxytrack listed such files as 1969. Move the 64-bit day count #1982 added for cookie Expires into htsdate.c as hts_utc_seconds(), shared with proxytrack, and convert every timegm() site through it. hts_time_clip() bounds the result where a time_t is needed: a file time stamps the last second a 32-bit time_t holds, never 1901. The FTP resume check refuses a clipped date, because two clipped dates would compare equal. The lock file's START= line is printed in 64 bits. Test 659 checks dates on both sides of 2038, 2106 and 9999 through the date parser, Retry-After and a file mtime. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Xavier Roche --- src/htsbauth.c | 19 +----- src/htscoremain.c | 2 +- src/htsdate.c | 32 ++++++++++ src/htsdate.h | 12 +++- src/htsftp.c | 8 ++- src/htslib.c | 33 ++++------ src/htslib.h | 7 +-- src/htslib_selftest.c | 116 ++++++++++++++++++++++++++++++++++++ src/proxy/proxytrack.c | 6 +- src/proxy/proxytrack.h | 2 +- src/proxy/store.c | 6 +- tests/659_engine-y2038.test | 14 +++++ 12 files changed, 202 insertions(+), 55 deletions(-) create mode 100644 tests/659_engine-y2038.test diff --git a/src/htsbauth.c b/src/htsbauth.c index caac90c55..9f4368a97 100644 --- a/src/htsbauth.c +++ b/src/htsbauth.c @@ -350,23 +350,6 @@ hts_boolean cookie_max_age_time(const char *value, int64_t now, int64_t *when) { return HTS_TRUE; } -/* Return the Unix time of the UTC date in tm, in 64 bits because a 32-bit - time_t ends in 2038. The calendar year must be 1 or later, and tm_mon 0 to - 11, as convert_time_rfc822_pivot yields them. */ -static int64_t cookie_utc_seconds(const struct tm *tm) { - // days from the civil date, counting years from March (Howard Hinnant) - const int64_t y = (int64_t) tm->tm_year + 1900 - (tm->tm_mon < 2 ? 1 : 0); - const int64_t era = y / 400; - const int64_t yoe = y - era * 400; - const int64_t doy = (153 * (int64_t) ((tm->tm_mon + 10) % 12) + 2) / 5 + - (int64_t) tm->tm_mday - 1; - const int64_t days = - era * 146097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719468; - - return days * 86400 + (int64_t) tm->tm_hour * 3600 + - (int64_t) tm->tm_min * 60 + tm->tm_sec; -} - /* See htsbauth.h. */ hts_boolean cookie_expires_time(const char *value, int64_t now, int64_t *when) { struct tm tm; @@ -374,7 +357,7 @@ hts_boolean cookie_expires_time(const char *value, int64_t now, int64_t *when) { // RFC 6265 5.1.1 reads a two-digit year below 70 as 20xx if (convert_time_rfc822_pivot(&tm, value, 69) == NULL) return HTS_FALSE; - *when = cookie_bound_expiry(cookie_utc_seconds(&tm), now); + *when = cookie_bound_expiry(hts_utc_seconds(&tm), now); return HTS_TRUE; } diff --git a/src/htscoremain.c b/src/htscoremain.c index 5f37eb582..49477bd41 100644 --- a/src/htscoremain.c +++ b/src/htscoremain.c @@ -371,7 +371,7 @@ static hts_boolean write_progress_lock(httrackp *opt, const char *path, fprintf(fp, "UID=%d\n", (int) getuid()); fprintf(fp, "GID=%d\n", (int) getuid()); #endif - fprintf(fp, "START=%d\n", (int) time(NULL)); + fprintf(fp, "START=" LLintP "\n", (LLint) time(NULL)); #endif fclose(fp); /* Whole-second filesystems tie two files written in the same second, and the diff --git a/src/htsdate.c b/src/htsdate.c index 532877a62..7d8922bd2 100644 --- a/src/htsdate.c +++ b/src/htsdate.c @@ -150,3 +150,35 @@ struct tm *convert_time_rfc822_pivot(struct tm *result, const char *s, } return NULL; } + +/* See htsdate.h. */ +int64_t hts_utc_seconds(const struct tm *tm) { + // days from the civil date, counting years from March (Howard Hinnant) + int64_t y = (int64_t) tm->tm_year + 1900 + tm->tm_mon / 12; + int64_t m = tm->tm_mon % 12; + int64_t era, yoe, doy, days; + + if (m < 0) { + m += 12; + y--; + } + if (m < 2) + y--; + era = (y >= 0 ? y : y - 399) / 400; + yoe = y - era * 400; + doy = (153 * ((m + 10) % 12) + 2) / 5 + (int64_t) tm->tm_mday - 1; + days = era * 146097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719468; + return days * 86400 + (int64_t) tm->tm_hour * 3600 + + (int64_t) tm->tm_min * 60 + tm->tm_sec; +} + +/* See htsdate.h. */ +time_t hts_time_clip(int64_t t) { + if (sizeof(time_t) < sizeof(int64_t)) { + if (t > INT32_MAX) + return (time_t) INT32_MAX; + if (t < INT32_MIN) + return (time_t) INT32_MIN; + } + return (time_t) t; +} diff --git a/src/htsdate.h b/src/htsdate.h index bf324fc14..7d5860005 100644 --- a/src/htsdate.h +++ b/src/htsdate.h @@ -33,12 +33,13 @@ Please visit our Website: http://www.httrack.com #ifndef HTSDATE_DEFH #define HTSDATE_DEFH +#include #include /* Parse an RFC822 date such as "Sun, 06 Nov 1994 08:49:37 GMT" into "result", which is returned, or NULL if "s" does not parse. The fields are GMT, so a - caller wanting a time_t uses timegm(), never mktime() (#1491). Its own file - so proxytrack, which does not link the library, can share it. */ + caller wanting seconds uses hts_utc_seconds(), never mktime() (#1491). Its + own file so proxytrack, which does not link the library, can share it. */ struct tm *convert_time_rfc822(struct tm *result, const char *s); /* As convert_time_rfc822, but a two-digit year up to PIVOT means 20xx and one @@ -46,4 +47,11 @@ struct tm *convert_time_rfc822(struct tm *result, const char *s); struct tm *convert_time_rfc822_pivot(struct tm *result, const char *s, int pivot); +/* Return the Unix time of the UTC date in tm, in 64 bits because a 32-bit + time_t ends in 2038. Out-of-range fields carry over as with timegm(). */ +int64_t hts_utc_seconds(const struct tm *tm); + +/* Return t as a time_t, clipped to the range this platform's time_t holds. */ +time_t hts_time_clip(int64_t t); + #endif diff --git a/src/htsftp.c b/src/htsftp.c index 338fffa97..ba58e656b 100644 --- a/src/htsftp.c +++ b/src/htsftp.c @@ -1025,7 +1025,13 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) { strlncatbuff(back->r.lastmodified, date, sizeof(back->r.lastmodified), sizeof(back->r.lastmodified) - 1); - remote_mtime = timegm(&remote_tm); + { + const int64_t secs = hts_utc_seconds(&remote_tm); + + // a clipped date cannot prove the copy matches + if ((int64_t) hts_time_clip(secs) == secs) + remote_mtime = (time_t) secs; + } } } diff --git a/src/htslib.c b/src/htslib.c index 235ec5720..0a6e06ad4 100644 --- a/src/htslib.c +++ b/src/htslib.c @@ -3731,26 +3731,17 @@ void time_local_rfc822(char *s) { s[0] = '\0'; } -/* convertir une chaine en temps */ -static time_t getGMT(struct tm *tm) { - time_t t = timegm(tm); - - if (t != (time_t) - 1 && t != (time_t) 0) { - return (time_t) t; - } - return (time_t) -1; -} - /* sets file time. -1 if error */ /* Note: utf-8 */ int set_filetime(const char *file, struct tm *tm_time) { - time_t t = getGMT(tm_time); + const int64_t t = hts_utc_seconds(tm_time); - if (t != (time_t) - 1) { + if (t != -1 && t != 0) { STRUCT_UTIMBUF tim; memset(&tim, 0, sizeof(tim)); - tim.actime = tim.modtime = t; + // a date past what time_t holds stamps its last second, never 1901 + tim.actime = tim.modtime = hts_time_clip(t); return UTIME(file, &tim); } return -1; @@ -3779,26 +3770,24 @@ int hts_parse_retry_after(const char *value, time_t now) { } { - const time_t when = hts_parse_date(p, 50); + const int64_t when = hts_parse_date(p, 50); - if (when == (time_t) -1) + if (when == -1) return -1; - if (when <= now) + if (when <= (int64_t) now) return 0; - /* widen before subtracting: two time_t values can differ by more than an - int holds */ - if ((LLint) when - (LLint) now > (LLint) INT_MAX) + if (when - (int64_t) now > INT_MAX) return INT_MAX; - return (int) (when - now); + return (int) (when - (int64_t) now); } } /* See htslib.h. */ -time_t hts_parse_date(const char *s, int pivot) { +int64_t hts_parse_date(const char *s, int pivot) { struct tm buffer; struct tm *const tm_s = convert_time_rfc822_pivot(&buffer, s, pivot); - return tm_s != NULL ? timegm(tm_s) : (time_t) -1; + return tm_s != NULL ? hts_utc_seconds(tm_s) : -1; } /* sets file time from RFC822 date+time, -1 if error*/ diff --git a/src/htslib.h b/src/htslib.h index 20755dca5..0de8e45e1 100644 --- a/src/htslib.h +++ b/src/htslib.h @@ -246,9 +246,9 @@ void hts_init_htsblk(htsblk * r); no usable delay. `now` dates the HTTP-date form, and the caller caps. */ int hts_parse_retry_after(const char *value, time_t now); -/* Read the HTTP date S as a time_t, with a two-digit year up to PIVOT in 20xx - (see convert_time_rfc822_pivot). (time_t) -1 when S is not a date. */ -time_t hts_parse_date(const char *s, int pivot); +/* Read the HTTP date S as Unix seconds, with a two-digit year up to PIVOT in + 20xx (see convert_time_rfc822_pivot). -1 when S is not a date. */ +int64_t hts_parse_date(const char *s, int pivot); // attach specific project log to hachtable logger void hts_set_hash_handler(coucal hashtable, httrackp *opt); @@ -843,7 +843,6 @@ HTS_STATIC int strcmpnocase(const char *a, const char *b) { #ifdef _MSC_VER #define fseeko _fseeki64 #define ftello _ftelli64 -#define timegm _mkgmtime #endif #define strfield2(f,s) ( (strlen(f)!=strlen(s)) ? 0 : (strfield(f,s)) ) diff --git a/src/htslib_selftest.c b/src/htslib_selftest.c index 3b2d378bf..7760770f2 100644 --- a/src/htslib_selftest.c +++ b/src/htslib_selftest.c @@ -1742,6 +1742,120 @@ static int st_localtime(httrackp *opt, int argc, char **argv) { return err; } +/* Dates on both sides of 2038-01-19T03:14:07Z, where a 32-bit time_t ends. + argv[0] is a scratch directory for the file-time check. */ +static int st_y2038(httrackp *opt, int argc, char **argv) { + static const struct { + const char *date; + int64_t secs; + } cases[] = { + {"Thu, 01 Jan 1970 00:00:00 GMT", 0}, + {"Wed, 31 Dec 1969 23:59:59 GMT", -1}, + {"Fri, 13 Dec 1901 20:45:52 GMT", -2147483648LL}, + {"Tue, 29 Feb 2000 12:00:00 GMT", 951825600}, + {"Tue, 19 Jan 2038 03:14:07 GMT", 2147483647}, + {"Tue, 19 Jan 2038 03:14:08 GMT", 2147483648LL}, + {"Sun, 07 Feb 2106 06:28:15 GMT", 4294967295LL}, + {"Sun, 07 Feb 2106 06:28:16 GMT", 4294967296LL}, + {"Fri, 31 Dec 9999 23:59:59 GMT", 253402300799LL}, + }; + + const time_t ref = (time_t) 2147483640; /* 2038-01-19T03:14:00Z */ + const hts_boolean narrow = sizeof(time_t) < sizeof(int64_t); + size_t i; + int err = 0; + + (void) opt; + for (i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + const int64_t got = hts_parse_date(cases[i].date, 50); + + if (got != cases[i].secs) { + printf("y2038: \"%s\" gave " LLintP ", expected " LLintP "\n", + cases[i].date, (LLint) got, (LLint) cases[i].secs); + err = 1; + } + } + + /* Out-of-range fields carry over as timegm() does. */ + { + struct tm tm; + + memset(&tm, 0, sizeof(tm)); + tm.tm_year = 138; + tm.tm_mon = 12; /* January 2039 */ + tm.tm_mday = 0; /* the day before: 2038-12-31 */ + tm.tm_hour = -1; + if (hts_utc_seconds(&tm) != 2177362800LL) { + printf("y2038: unnormalised fields gave " LLintP "\n", + (LLint) hts_utc_seconds(&tm)); + err = 1; + } + tm.tm_year = 138; + tm.tm_mon = -13; /* December 2036 */ + tm.tm_mday = 1; + tm.tm_hour = 0; + if (hts_utc_seconds(&tm) != 2111702400LL) { + printf("y2038: a negative month gave " LLintP "\n", + (LLint) hts_utc_seconds(&tm)); + err = 1; + } + } + + /* A wait across the 2038 boundary, and one too long for an int. */ + if (hts_parse_retry_after("Tue, 19 Jan 2038 03:14:10 GMT", ref) != 10 || + hts_parse_retry_after("Sat, 19 Jan 2039 03:14:00 GMT", ref) != + 365 * 86400 || + hts_parse_retry_after("Sun, 07 Feb 2106 06:28:16 GMT", ref) != INT_MAX || + hts_parse_retry_after("Fri, 31 Dec 9999 23:59:59 GMT", ref) != INT_MAX) { + printf("y2038: a Retry-After date past 2038 was misread\n"); + err = 1; + } + + /* Clip, never wrap. */ + if ((int64_t) hts_time_clip(2147483648LL) != + (narrow ? 2147483647LL : 2147483648LL) || + (int64_t) hts_time_clip(253402300799LL) != + (narrow ? 2147483647LL : 253402300799LL) || + (int64_t) hts_time_clip(-2147483649LL) != + (narrow ? -2147483648LL : -2147483649LL) || + (int64_t) hts_time_clip(2147483647LL) != 2147483647LL) { + printf("y2038: hts_time_clip wrapped\n"); + err = 1; + } + + /* A mirrored file dated after 2038 keeps that date, or the last second a + 32-bit time_t holds. */ + if (argc >= 1) { + char path[HTS_URLMAXSIZE]; + FILE *fp; + + snprintf(path, sizeof(path), "%s/y2038.bin", argv[0]); + structcheck(path); + fp = FOPEN(path, "wb"); + if (fp == NULL) { + printf("y2038: cannot write %s\n", path); + return 1; + } + fclose(fp); + for (i = 5; i <= 6; i++) { + const int64_t want = narrow ? 2147483647LL : cases[i].secs; + time_t got; + + if (set_filetime_rfc822(path, cases[i].date) != 0) { + printf("y2038: cannot date %s as \"%s\"\n", path, cases[i].date); + err = 1; + } else if ((int64_t) (got = get_filetime(path)) != want) { + printf("y2038: \"%s\" dated the file " LLintP ", expected " LLintP "\n", + cases[i].date, (LLint) got, (LLint) want); + err = 1; + } + } + } + + printf("y2038: %s\n", err ? "FAIL" : "OK"); + return err; +} + /* The x[strlen(x) - 1] class (#770), its pointer spelling x + strlen(x) - 1 (#781) and its size_t index spelling (#821). The string starts mid-arena so the byte it must not touch is a real neighbour; poisoned with '#', not 0, or @@ -3513,6 +3627,8 @@ const struct selftest_entry selftests_lib[] = { "the fields and copies the span primitive makes", st_span}, {"gmtime", "", "hts_gmtime() fills the caller's buffer, not a static (#794)", st_gmtime}, + {"y2038", "", "dates before and after 2038 read as 64-bit seconds", + st_y2038}, {"localtime", "", "hts_localtime() and get_filetime_rfc822()'s GMT labelling (#806)", st_localtime}, diff --git a/src/proxy/proxytrack.c b/src/proxy/proxytrack.c index c44467782..d38698d82 100644 --- a/src/proxy/proxytrack.c +++ b/src/proxy/proxytrack.c @@ -610,14 +610,14 @@ static void proxytrack_add_DAV_Item(String * item, String * buff, } } -/* The RFC822 date of a cache entry as a time_t, or 0 if it does not parse. - The fields are GMT, so timegm() and never mktime() (#1491). */ +/* The RFC822 date of a cache entry as a time_t, clipped to its range, or 0 if + it does not parse. */ static time_t get_time_rfc822(const char *s) { struct tm result; if (convert_time_rfc822(&result, s) == NULL) return (time_t) 0; - return timegm(&result); + return hts_time_clip(hts_utc_seconds(&result)); } static PT_Element proxytrack_process_DAV_Request(PT_Indexes indexes, diff --git a/src/proxy/proxytrack.h b/src/proxy/proxytrack.h index 5737b325d..d680c3988 100644 --- a/src/proxy/proxytrack.h +++ b/src/proxy/proxytrack.h @@ -278,7 +278,7 @@ HTS_UNUSED static struct tm PT_GetTime(time_t t) { HTS_UNUSED static int set_filetime(const char *file, struct tm *tm_time) { struct utimbuf tim; - tim.actime = tim.modtime = timegm(tm_time); + tim.actime = tim.modtime = hts_time_clip(hts_utc_seconds(tm_time)); return utime(file, &tim); } HTS_UNUSED static int set_filetime_time_t(const char *file, time_t t) { diff --git a/src/proxy/store.c b/src/proxy/store.c index 8a460047e..55e953ac5 100644 --- a/src/proxy/store.c +++ b/src/proxy/store.c @@ -2276,10 +2276,10 @@ static int getDigit4(const char *const pos) { } static time_t getGMT(struct tm *tm) { - time_t t = timegm(tm); + const int64_t t = hts_utc_seconds(tm); - if (t != (time_t) - 1 && t != (time_t) 0) { - return t; + if (t != -1 && t != 0) { + return hts_time_clip(t); } return (time_t) - 1; } diff --git a/tests/659_engine-y2038.test b/tests/659_engine-y2038.test new file mode 100644 index 000000000..983360dcb --- /dev/null +++ b/tests/659_engine-y2038.test @@ -0,0 +1,14 @@ +#!/bin/bash +# +# Dates on both sides of 2038 read as 64-bit seconds: HTTP dates, Retry-After +# and mirrored file times, clipped rather than wrapped on a 32-bit time_t. + +set -euo pipefail + +# shellcheck source=tests/testlib.sh +. "${0%"${0##*/}"}./testlib.sh" + +tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_y2038.XXXXXX") || exit 1 +cleanup_push rm -rf "$tmpdir" + +expect_ok "y2038" httrack -O "${tmpdir}/o1" -#test=y2038 "${tmpdir}/data" From 4bbaca1b841088e5cee3d80c9bfbc1bdddbcec4d Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 21 Oct 2026 14:38:53 +0200 Subject: [PATCH 2/5] Test a month offset that crosses a year boundary twice Co-Authored-By: Claude Opus 5.5 Signed-off-by: Xavier Roche --- src/htslib_selftest.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/htslib_selftest.c b/src/htslib_selftest.c index 7760770f2..edb07745c 100644 --- a/src/htslib_selftest.c +++ b/src/htslib_selftest.c @@ -1791,10 +1791,10 @@ static int st_y2038(httrackp *opt, int argc, char **argv) { err = 1; } tm.tm_year = 138; - tm.tm_mon = -13; /* December 2036 */ + tm.tm_mon = -23; /* February 2036 */ tm.tm_mday = 1; tm.tm_hour = 0; - if (hts_utc_seconds(&tm) != 2111702400LL) { + if (hts_utc_seconds(&tm) != 2085436800LL) { printf("y2038: a negative month gave " LLintP "\n", (LLint) hts_utc_seconds(&tm)); err = 1; From 05df3ae010087a954eac372f816acbfe1048d986 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 21 Oct 2026 15:01:45 +0200 Subject: [PATCH 3/5] Share the file-time and exact-fit date helpers, and test more edges Review round 1 on #2002. set_filetime and proxytrack's ARC timestamp now share hts_tm_filetime(), and the FTP MDTM guard is hts_time_exact(), tested in st_y2038. New rows: 1900 and 2100 leap days, years 0 and -1, a Retry-After date equal to the reference, and an epoch date that must leave a file's time alone. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Xavier Roche --- src/htsdate.c | 21 +++++++++++++++++++ src/htsdate.h | 14 +++++++++++-- src/htsftp.c | 11 ++++------ src/htslib.c | 7 +++---- src/htslib_selftest.c | 40 ++++++++++++++++++++++++++++++++++--- src/proxy/store.c | 7 ++----- tests/659_engine-y2038.test | 3 +-- 7 files changed, 80 insertions(+), 23 deletions(-) diff --git a/src/htsdate.c b/src/htsdate.c index 7d8922bd2..22b0a67ad 100644 --- a/src/htsdate.c +++ b/src/htsdate.c @@ -182,3 +182,24 @@ time_t hts_time_clip(int64_t t) { } return (time_t) t; } + +/* See htsdate.h. */ +hts_boolean hts_time_exact(int64_t t, time_t *out) { + const time_t clipped = hts_time_clip(t); + + if ((int64_t) clipped != t) + return HTS_FALSE; + *out = clipped; + return HTS_TRUE; +} + +/* See htsdate.h. */ +hts_boolean hts_tm_filetime(const struct tm *tm, time_t *out) { + const int64_t t = hts_utc_seconds(tm); + + if (t == -1 || t == 0) + return HTS_FALSE; + // A date past what time_t holds stamps its last second, never 1901. + *out = hts_time_clip(t); + return HTS_TRUE; +} diff --git a/src/htsdate.h b/src/htsdate.h index 7d5860005..b5f5aa214 100644 --- a/src/htsdate.h +++ b/src/htsdate.h @@ -33,13 +33,16 @@ Please visit our Website: http://www.httrack.com #ifndef HTSDATE_DEFH #define HTSDATE_DEFH +#include "htsglobal.h" + #include #include /* Parse an RFC822 date such as "Sun, 06 Nov 1994 08:49:37 GMT" into "result", which is returned, or NULL if "s" does not parse. The fields are GMT, so a - caller wanting seconds uses hts_utc_seconds(), never mktime() (#1491). Its - own file so proxytrack, which does not link the library, can share it. */ + caller wanting seconds uses hts_utc_seconds(), never mktime() (#1491). This + file is separate so proxytrack, which does not link the library, can share + it. */ struct tm *convert_time_rfc822(struct tm *result, const char *s); /* As convert_time_rfc822, but a two-digit year up to PIVOT means 20xx and one @@ -54,4 +57,11 @@ int64_t hts_utc_seconds(const struct tm *tm); /* Return t as a time_t, clipped to the range this platform's time_t holds. */ time_t hts_time_clip(int64_t t); +/* Does t fit this platform's time_t exactly? If so, store it in *out. */ +hts_boolean hts_time_exact(int64_t t, time_t *out); + +/* Store the file time for the UTC date in tm in *out, clipped to time_t. False + when the date is the epoch or one second before it, which mean no date. */ +hts_boolean hts_tm_filetime(const struct tm *tm, time_t *out); + #endif diff --git a/src/htsftp.c b/src/htsftp.c index ba58e656b..169a8cd00 100644 --- a/src/htsftp.c +++ b/src/htsftp.c @@ -1025,13 +1025,10 @@ int run_launch_ftp(FTPDownloadStruct * pStruct) { strlncatbuff(back->r.lastmodified, date, sizeof(back->r.lastmodified), sizeof(back->r.lastmodified) - 1); - { - const int64_t secs = hts_utc_seconds(&remote_tm); - - // a clipped date cannot prove the copy matches - if ((int64_t) hts_time_clip(secs) == secs) - remote_mtime = (time_t) secs; - } + // a clipped date cannot prove the copy matches + if (!hts_time_exact(hts_utc_seconds(&remote_tm), + &remote_mtime)) + remote_mtime = (time_t) -1; } } diff --git a/src/htslib.c b/src/htslib.c index 0a6e06ad4..b0a5840db 100644 --- a/src/htslib.c +++ b/src/htslib.c @@ -3734,14 +3734,13 @@ void time_local_rfc822(char *s) { /* sets file time. -1 if error */ /* Note: utf-8 */ int set_filetime(const char *file, struct tm *tm_time) { - const int64_t t = hts_utc_seconds(tm_time); + time_t t; - if (t != -1 && t != 0) { + if (hts_tm_filetime(tm_time, &t)) { STRUCT_UTIMBUF tim; memset(&tim, 0, sizeof(tim)); - // a date past what time_t holds stamps its last second, never 1901 - tim.actime = tim.modtime = hts_time_clip(t); + tim.actime = tim.modtime = t; return UTIME(file, &tim); } return -1; diff --git a/src/htslib_selftest.c b/src/htslib_selftest.c index edb07745c..bcdc0865f 100644 --- a/src/htslib_selftest.c +++ b/src/htslib_selftest.c @@ -1743,7 +1743,8 @@ static int st_localtime(httrackp *opt, int argc, char **argv) { } /* Dates on both sides of 2038-01-19T03:14:07Z, where a 32-bit time_t ends. - argv[0] is a scratch directory for the file-time check. */ + argv[0] is a scratch directory for the file-time check. The clip rows only + bite on a 32-bit time_t, which CI's linux-i386 job runs. */ static int st_y2038(httrackp *opt, int argc, char **argv) { static const struct { const char *date; @@ -1758,6 +1759,9 @@ static int st_y2038(httrackp *opt, int argc, char **argv) { {"Sun, 07 Feb 2106 06:28:15 GMT", 4294967295LL}, {"Sun, 07 Feb 2106 06:28:16 GMT", 4294967296LL}, {"Fri, 31 Dec 9999 23:59:59 GMT", 253402300799LL}, + /* centuries that are not leap years */ + {"Thu, 01 Mar 1900 00:00:00 GMT", -2203891200LL}, + {"Mon, 01 Mar 2100 00:00:00 GMT", 4107542400LL}, }; const time_t ref = (time_t) 2147483640; /* 2038-01-19T03:14:00Z */ @@ -1799,10 +1803,23 @@ static int st_y2038(httrackp *opt, int argc, char **argv) { (LLint) hts_utc_seconds(&tm)); err = 1; } + /* years 0 and -1, below the first 400-year era */ + tm.tm_year = -1900; + tm.tm_mon = 0; + if (hts_utc_seconds(&tm) != -62167219200LL) { + printf("y2038: year 0 gave " LLintP "\n", (LLint) hts_utc_seconds(&tm)); + err = 1; + } + tm.tm_year = -1901; + if (hts_utc_seconds(&tm) != -62198755200LL) { + printf("y2038: year -1 gave " LLintP "\n", (LLint) hts_utc_seconds(&tm)); + err = 1; + } } /* A wait across the 2038 boundary, and one too long for an int. */ - if (hts_parse_retry_after("Tue, 19 Jan 2038 03:14:10 GMT", ref) != 10 || + if (hts_parse_retry_after("Tue, 19 Jan 2038 03:14:00 GMT", ref) != 0 || + hts_parse_retry_after("Tue, 19 Jan 2038 03:14:10 GMT", ref) != 10 || hts_parse_retry_after("Sat, 19 Jan 2039 03:14:00 GMT", ref) != 365 * 86400 || hts_parse_retry_after("Sun, 07 Feb 2106 06:28:16 GMT", ref) != INT_MAX || @@ -1811,7 +1828,7 @@ static int st_y2038(httrackp *opt, int argc, char **argv) { err = 1; } - /* Clip, never wrap. */ + /* A date past time_t clips and never wraps. */ if ((int64_t) hts_time_clip(2147483648LL) != (narrow ? 2147483647LL : 2147483648LL) || (int64_t) hts_time_clip(253402300799LL) != @@ -1822,6 +1839,16 @@ static int st_y2038(httrackp *opt, int argc, char **argv) { printf("y2038: hts_time_clip wrapped\n"); err = 1; } + { + time_t got = (time_t) 7; + + if (hts_time_exact(2147483648LL, &got) != !narrow || + (int64_t) got != (narrow ? 7 : 2147483648LL) || + !hts_time_exact(2147483647LL, &got) || (int64_t) got != 2147483647LL) { + printf("y2038: hts_time_exact accepted a clipped date\n"); + err = 1; + } + } /* A mirrored file dated after 2038 keeps that date, or the last second a 32-bit time_t holds. */ @@ -1850,6 +1877,13 @@ static int st_y2038(httrackp *opt, int argc, char **argv) { err = 1; } } + /* The epoch means no date: refused, and the file keeps its time. */ + if (set_filetime_rfc822(path, cases[4].date) != 0 || + set_filetime_rfc822(path, cases[0].date) != -1 || + (int64_t) get_filetime(path) != cases[4].secs) { + printf("y2038: the epoch changed the file's date\n"); + err = 1; + } } printf("y2038: %s\n", err ? "FAIL" : "OK"); diff --git a/src/proxy/store.c b/src/proxy/store.c index 55e953ac5..afa4a1b1d 100644 --- a/src/proxy/store.c +++ b/src/proxy/store.c @@ -2276,12 +2276,9 @@ static int getDigit4(const char *const pos) { } static time_t getGMT(struct tm *tm) { - const int64_t t = hts_utc_seconds(tm); + time_t t; - if (t != -1 && t != 0) { - return hts_time_clip(t); - } - return (time_t) - 1; + return hts_tm_filetime(tm, &t) ? t : (time_t) -1; } static time_t getArcTimestamp(const char *const line) { diff --git a/tests/659_engine-y2038.test b/tests/659_engine-y2038.test index 983360dcb..63505b052 100644 --- a/tests/659_engine-y2038.test +++ b/tests/659_engine-y2038.test @@ -1,7 +1,6 @@ #!/bin/bash # -# Dates on both sides of 2038 read as 64-bit seconds: HTTP dates, Retry-After -# and mirrored file times, clipped rather than wrapped on a 32-bit time_t. +# Dates on both sides of 2038 read correctly, and clip on a 32-bit time_t. set -euo pipefail From fd366a17db4f91190b1b86a06fb6e98eea98ef26 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 21 Oct 2026 15:15:59 +0200 Subject: [PATCH 4/5] Test the -1 date and the low time_t bound in the 2038 self-test Co-Authored-By: Claude Opus 5.5 Signed-off-by: Xavier Roche --- src/htslib_selftest.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/htslib_selftest.c b/src/htslib_selftest.c index bcdc0865f..29d162ae7 100644 --- a/src/htslib_selftest.c +++ b/src/htslib_selftest.c @@ -1844,7 +1844,8 @@ static int st_y2038(httrackp *opt, int argc, char **argv) { if (hts_time_exact(2147483648LL, &got) != !narrow || (int64_t) got != (narrow ? 7 : 2147483648LL) || - !hts_time_exact(2147483647LL, &got) || (int64_t) got != 2147483647LL) { + !hts_time_exact(2147483647LL, &got) || (int64_t) got != 2147483647LL || + hts_time_exact(-2147483649LL, &got) != !narrow) { printf("y2038: hts_time_exact accepted a clipped date\n"); err = 1; } @@ -1877,9 +1878,10 @@ static int st_y2038(httrackp *opt, int argc, char **argv) { err = 1; } } - /* The epoch means no date: refused, and the file keeps its time. */ + /* The epoch and -1 mean no date: refused, and the file keeps its time. */ if (set_filetime_rfc822(path, cases[4].date) != 0 || set_filetime_rfc822(path, cases[0].date) != -1 || + set_filetime_rfc822(path, cases[1].date) != -1 || (int64_t) get_filetime(path) != cases[4].secs) { printf("y2038: the epoch changed the file's date\n"); err = 1; From 5354810f8935d5d014cd02a0aae6ad5ffeaed127 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 21 Oct 2026 18:03:23 +0200 Subject: [PATCH 5/5] Include the headers the 2038 code uses directly Co-Authored-By: Claude Opus 5.5 Signed-off-by: Xavier Roche --- src/htsdate.c | 1 + src/htslib_selftest.c | 1 + 2 files changed, 2 insertions(+) diff --git a/src/htsdate.c b/src/htsdate.c index 22b0a67ad..e08f79405 100644 --- a/src/htsdate.c +++ b/src/htsdate.c @@ -38,6 +38,7 @@ Please visit our Website: http://www.httrack.com #include #include +#include #include /* hts_lowcase() lives in the library proxytrack does not link. */ diff --git a/src/htslib_selftest.c b/src/htslib_selftest.c index 29d162ae7..d9180323f 100644 --- a/src/htslib_selftest.c +++ b/src/htslib_selftest.c @@ -35,6 +35,7 @@ Please visit our Website: http://www.httrack.com #include "htsname.h" #include "htsfetch.h" +#include #include #ifndef _WIN32 #include /* the forking sigterm self-test reaps its children */