From 7aa276db3c0f6e335914401f961d6e76587b141b Mon Sep 17 00:00:00 2001 From: Elliott Hughes Date: Tue, 12 Mar 2019 18:00:26 -0700 Subject: date: fix various time zone/daylight time issues. Sunday's transition in the US broke a bunch of the tests. Worse, it broke some of the QA folks' scripts. Finally, the boil that is date's handling of time zones and daylight time has come to a head... This patch fixes the newly-failing tests *and* the other tests that were checked in failing to serve as TODOs. I've resolved the test TODOs about whether implied year/century in POSIX format should mean the current year or 1900 in favor of the current year. Both busybox and coreutils agree, and Rob fixed the code recently so toybox agrees too, but without fixing the tests. I've switched tests from Europe/London to Europe/Berlin to avoid disagreements between C libraries about whether to say "GMT" or "UTC" when daylight savings is not in force. The majority of this patch implements what I'd been unsuccessfully trying to explain on the list: that to correctly implement the distinct input and output time zones (as demonstrated in the three failing tests we've been carrying around for a while), we should switch to working with time_t internally rather than struct tm. I've also added the code to temporarily switch to the input time zone (and back again). All the tests now pass. --- tests/date.test | 25 +++++---- toys/posix/date.c | 157 ++++++++++++++++++++++++++++++++---------------------- 2 files changed, 106 insertions(+), 76 deletions(-) diff --git a/tests/date.test b/tests/date.test index fd45773f..285d3dec 100644 --- a/tests/date.test +++ b/tests/date.test @@ -6,29 +6,29 @@ # Use a consistent TZ for these tests, but not GMT/UTC because that # makes mistakes harder to spot. -tz=Europe/London +tz=Europe/Berlin # Unix date parsing. -testing "-d @0" "TZ=$tz date -d @0 2>&1" "Thu Jan 1 01:00:00 BST 1970\n" "" "" +testing "-d @0" "TZ=$tz date -d @0 2>&1" "Thu Jan 1 01:00:00 CET 1970\n" "" "" testing "-d @0x123 invalid" "TZ=$tz date -d @0x123 2>/dev/null || echo expected error" "expected error\n" "" "" # POSIX format with 2- and 4-digit years. # All SKIP_HOST=1 because coreutils rejects POSIX format dates supplied to -d. -# TODO: busybox thinks this should use the current year, not 1900, which would make more sense? +# These expected values are from running on the host without -d (not as root!). SKIP_HOST=1 testing "-d MMDDhhmm" \ - "TZ=$tz date -d 06021234 2>&1" "Sun Jun 2 12:34:00 UTC 1900\n" "" "" + "TZ=$tz date -d 06021234 2>&1" "Sun Jun 2 12:34:00 CEST 2019\n" "" "" SKIP_HOST=1 testing "-d MMDDhhmmYY.SS" \ - "TZ=$tz date -d 1110143115.30 2>&1" "Sun Nov 10 14:31:30 UTC 1915\n" "" "" + "TZ=$tz date -d 1110143115.30 2>&1" "Tue Nov 10 14:31:30 CET 2015\n" "" "" # busybox thinks this is the year 603 (ISO time 0602-12-34 19:82 with out of range fields normalized). SKIP_HOST=1 testing "-d MMDDhhmmCCYY" \ - "TZ=$tz date -d 060212341982 2>&1" "Sun Jun 2 12:34:00 UTC 1982\n" "" "" + "TZ=$tz date -d 060212341982 2>&1" "Wed Jun 2 12:34:00 CEST 1982\n" "" "" SKIP_HOST=1 testing "-d MMDDhhmmCCYY.SS" \ - "TZ=$tz date -d 111014312015.30 2>&1" "Sun Nov 10 14:31:30 UTC 2015\n" "" "" + "TZ=$tz date -d 111014312015.30 2>&1" "Tue Nov 10 14:31:30 CET 2015\n" "" "" # ISO date format. -testing "-d 1980-01-02" "TZ=$tz date -d 1980-01-02 2>&1" "Wed Jan 2 00:00:00 GMT 1980\n" "" "" -testing "-d 1980-01-02 12:34" "TZ=$tz date -d '1980-01-02 12:34' 2>&1" "Wed Jan 2 12:34:00 GMT 1980\n" "" "" -testing "-d 1980-01-02 12:34:56" "TZ=$tz date -d '1980-01-02 12:34:56' 2>&1" "Wed Jan 2 12:34:56 GMT 1980\n" "" "" +testing "-d 1980-01-02" "TZ=$tz date -d 1980-01-02 2>&1" "Wed Jan 2 00:00:00 CET 1980\n" "" "" +testing "-d 1980-01-02 12:34" "TZ=$tz date -d '1980-01-02 12:34' 2>&1" "Wed Jan 2 12:34:00 CET 1980\n" "" "" +testing "-d 1980-01-02 12:34:56" "TZ=$tz date -d '1980-01-02 12:34:56' 2>&1" "Wed Jan 2 12:34:56 CET 1980\n" "" "" # Reject Unix times without a leading @. testing "Unix time missing @" "TZ=$tz date 1438053157 2>/dev/null || echo no" \ @@ -51,7 +51,6 @@ testing "just %" "touch -d 2012-01-23T12:34:56.123456789 f && date -r f +%" "%\n rm -f f # Test embedded TZ to take a date in one time zone and display it in another. -# TODO: not yet working correctly in toybox. -testing "TZ=" "TZ='America/Los_Angeles' date -d 'TZ=\"Europe/London\" 2018-01-04 08:00'" "Thu Jan 4 00:00:00 PST 2018\n" "" "" -testing "TZ=" "TZ='America/Los_Angeles' date -d 'TZ=\"Europe/London\" 2018-10-04 08:00'" "Thu Oct 4 00:00:00 PDT 2018\n" "" "" +testing "TZ=" "TZ='America/Los_Angeles' date -d 'TZ=\"Europe/Berlin\" 2018-01-04 08:00'" "Wed Jan 3 23:00:00 PST 2018\n" "" "" +testing "TZ=" "TZ='America/Los_Angeles' date -d 'TZ=\"Europe/Berlin\" 2018-10-04 08:00'" "Wed Oct 3 23:00:00 PDT 2018\n" "" "" testing "TZ= @" "TZ='America/Los_Angeles' date -d 'TZ=\"GMT\" @1533427200'" "Sat Aug 4 17:00:00 PDT 2018\n" "" "" diff --git a/toys/posix/date.c b/toys/posix/date.c index 09a548f5..43b558a5 100644 --- a/toys/posix/date.c +++ b/toys/posix/date.c @@ -29,6 +29,9 @@ config DATE YYYY-MM-DD [hh:mm[:ss]] ISO 8601 hh:mm[:ss] 24-hour time today + All input formats can be preceded by TZ="id" to set the input time zone + separately from the output time zone. Otherwise $TZ sets both. + +FORMAT specifies display format string using strftime(3) syntax: %% literal % %n newline %t tab @@ -41,7 +44,7 @@ config DATE %N nanosec (output only) %U Week of year (0-53 start sunday) %W Week of year (0-53 start monday) - %V Week of year (1-53 start monday, week < 4 days not part of this year) + %V Week of year (1-53 start monday, week < 4 days not part of this year) %D = "%m/%d/%y" %r = "%I : %M : %S %p" %T = "%H:%M:%S" %h = "%b" %x locale date %X locale time %c locale date/time @@ -56,10 +59,25 @@ GLOBALS( unsigned nano; ) -// Handle default posix date format (mmddhhmm[[cc]yy]) or @UNIX[.FRAC] -// returns 0 success, nonzero for error -static int parse_default(char *str, struct tm *tm) +static void check_range(int a, int low, int high) +{ + if (ahigh) error_exit("%d>%d", a, high); +} + +static void check_tm(struct tm *tm) { + check_range(tm->tm_sec, 0, 60); + check_range(tm->tm_min, 0, 59); + check_range(tm->tm_hour, 0, 23); + check_range(tm->tm_mday, 1, 31); + check_range(tm->tm_mon, 0, 11); +} + +// Returns 0 success, nonzero for error. +static int parse_formats(char *str, time_t *t) +{ + struct tm tm; time_t now; int len = 0, i; char *formats[] = { @@ -71,12 +89,9 @@ static int parse_default(char *str, struct tm *tm) // Parse @UNIXTIME[.FRACTION] if (*str == '@') { long long ll; - time_t tt; - // Collect seconds and nanoseconds - // Note: struct tm hasn't got a fractional seconds field, thus strptime() - // doesn't support it, so store nanoseconds out of band (in globals). - // tt and ll are separate because we can't guarantee time_t is 64 bit (yet). + // Collect seconds and nanoseconds. + // &ll is not just t because we can't guarantee time_t is 64 bit (yet). sscanf(str, "@%lld%n", &ll, &len); if (str[len]=='.') { str += len+1; @@ -86,9 +101,7 @@ static int parse_default(char *str, struct tm *tm) } } if (str[len]) return 1; - tt = ll; - localtime_r(&tt, tm); - + *t = ll; return 0; } @@ -97,25 +110,28 @@ static int parse_default(char *str, struct tm *tm) char *p; now = time(0); - localtime_r(&now, tm); - tm->tm_hour = tm->tm_min = tm->tm_sec = 0; - if ((p = strptime(str, formats[i], tm)) && !*p) return 0; + localtime_r(&now, &tm); + tm.tm_hour = tm.tm_min = tm.tm_sec = 0; + tm.tm_isdst = -1; + if ((p = strptime(str,formats[i],&tm)) && !*p) { + if ((*t = mktime(&tm)) != -1) return 0; + } } // Posix format? - sscanf(str, "%2u%2u%2u%2u%n", &tm->tm_mon, &tm->tm_mday, &tm->tm_hour, - &tm->tm_min, &len); + sscanf(str, "%2u%2u%2u%2u%n", &tm.tm_mon, &tm.tm_mday, &tm.tm_hour, + &tm.tm_min, &len); if (len != 8) return 1; str += len; - tm->tm_mon--; + tm.tm_mon--; - // If year specified, overwrite one we fetched earlier + // If year specified, overwrite one we fetched earlier. if (*str && *str != '.') { unsigned year; len = 0; sscanf(str, "%u%n", &year, &len); - if (len == 4) tm->tm_year = year - 1900; + if (len == 4) tm.tm_year = year - 1900; else if (len != 2) return 1; str += len; @@ -123,33 +139,55 @@ static int parse_default(char *str, struct tm *tm) // A "future" date in past is a century ahead. // A non-future date in the future is a century behind. if (len == 2) { - unsigned r1 = tm->tm_year % 100, r2 = (tm->tm_year + 50) % 100, - century = tm->tm_year - r1; + unsigned r1 = tm.tm_year % 100, r2 = (tm.tm_year + 50) % 100, + century = tm.tm_year - r1; if ((r1 < r2) ? (r1 < year && year < r2) : (year < r1 || year > r2)) { if (year < r1) year += 100; } else if (year > r1) year -= 100; - tm->tm_year = year + century; + tm.tm_year = year + century; } } + // Fractional part? if (*str == '.') { len = 0; - sscanf(str, ".%u%n", &tm->tm_sec, &len); + sscanf(str, ".%u%n", &tm.tm_sec, &len); str += len; - } else tm->tm_sec = 0; + } else tm.tm_sec = 0; - // Fix up weekday - now = mktime(tm); - localtime_r(&now, tm); + // Does that look like a valid date? + check_tm(&tm); + if ((*t = mktime(&tm)) == -1) return 1; - // shouldn't be any trailing garbage + // Shouldn't be any trailing garbage. return *str; } -static void check_range(int a, int low, int high) +// Handles any leading `TZ="blah" ` in the input string. +static int parse_date(char *str, time_t *t) { - if (ahigh) error_exit("%d>%d", a, high); + char *new_tz = NULL, *old_tz; + int result; + + if (!strncmp(str, "TZ=\"", 4)) { + // Extract the time zone and skip any whitespace. + new_tz = str+4; + str = strchr(new_tz, '"'); + if (!str) return 1; + *str++ = '\0'; + while (isspace(*str)) ++str; + + // Switch $TZ. + old_tz = getenv("TZ"); + setenv("TZ", new_tz, 1); + tzset(); + } + result = parse_formats(str, t); + if (new_tz) { + if (old_tz) setenv("TZ", old_tz, 1); + else unsetenv("TZ"); + } + return result; } // Print strftime plus %N escape(s). note: modifies fmt for %N @@ -183,17 +221,25 @@ static void puts_time(char *fmt, struct tm *tm) void date_main(void) { - char *setdate = *toys.optargs, *format_string = "%a %b %e %H:%M:%S %Z %Y"; - struct tm tm; - - memset(&tm, 0, sizeof(struct tm)); + char *setdate = *toys.optargs, *format_string = "%a %b %e %H:%M:%S %Z %Y", + *tz = NULL; + time_t t; + + if (FLAG(u)) { + tz = getenv("TZ"); + setenv("TZ", "UTC", 1); + tzset(); + } if (TT.d) { if (TT.D) { + struct tm tm = {}; char *s = strptime(TT.d, TT.D+(*TT.D=='+'), &tm); if (!s || *s) goto bad_showdate; - } else if (parse_default(TT.d, &tm)) goto bad_showdate; + check_tm(&tm); + if ((t = mktime(&tm)) == -1) goto bad_showdate; + } else if (parse_date(TT.d, &t)) goto bad_showdate; } else { struct timespec ts; struct stat st; @@ -203,7 +249,7 @@ void date_main(void) ts = st.st_mtim; } else clock_gettime(CLOCK_REALTIME, &ts); - ((toys.optflags & FLAG_u) ? gmtime_r : localtime_r)(&ts.tv_sec, &tm); + t = ts.tv_sec; TT.nano = ts.tv_nsec; } @@ -216,37 +262,22 @@ void date_main(void) // Set the date } else if (setdate) { - char *tz; struct timeval tv; - int u = toys.optflags & FLAG_u; - - if (parse_default(setdate, &tm)) goto bad_setdate; - - check_range(tm.tm_sec, 0, 60); - check_range(tm.tm_min, 0, 59); - check_range(tm.tm_hour, 0, 23); - check_range(tm.tm_mday, 1, 31); - check_range(tm.tm_mon, 0, 11); - - if (u) { - tz = getenv("TZ"); - setenv("TZ", "UTC", 1); - tzset(); - } - errno = 0; - tv.tv_sec = mktime(&tm); - if (errno) goto bad_setdate; - if (u) { - if (tz) setenv("TZ", tz, 1); - else unsetenv("TZ"); - tzset(); - } + if (parse_date(setdate, &t)) goto bad_setdate; + tv.tv_sec = t; tv.tv_usec = TT.nano/1000; if (settimeofday(&tv, NULL) < 0) perror_msg("cannot set date"); } - puts_time(format_string, &tm); + puts_time(format_string, localtime(&t)); + + if (FLAG(u)) { + if (tz) setenv("TZ", tz, 1); + else unsetenv("TZ"); + tzset(); + } + return; bad_showdate: -- cgit v1.2.3