Skip to content

touch: read a date with no zone in it as local time - #204

Open
metif12 wants to merge 2 commits into
vlang:mainfrom
metif12:fix/touch-datetime
Open

metif12 wants to merge 2 commits into
vlang:mainfrom
metif12:fix/touch-datetime

Conversation

@metif12

@metif12 metif12 commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

A string with no zone in it names a local time. This read it as UTC, so every
such date landed hours off. Measured against GNU on this machine at +0330:

touch -d '2020-01-02 03:04:05'
  GNU     2020-01-02 03:04:05
  before  2020-01-02 06:34:05

The offset cannot come from time.offset(), which reports the zone now. The same
zone wants a different offset depending on the date, and both of these were
measured rather than assumed:

TZ=Asia/Tehran        2021-06-07 08:09:10  wants +0430, and +0330 now
TZ=America/New_York   2020-01-02 00:00:00  wants -0500, and -0400 now

An earlier attempt here subtracted time.offset() and looked correct on every
case in the C locale; it was one hour out for June in Tehran, silently. The
offset in force at the date comes from zoneinfo's offset_at, which is what this
uses, applied twice so that a first guess landing on the far side of a DST
transition is corrected.

The tests could not have caught this

Every -d test in touch_test.v computed its expectation with

time.parse_iso8601(date).unix()

which is the very call the implementation makes. They agreed with the code by
construction, so they would have passed with the bug in place and would fail on
any machine that is not at UTC. The expectations are now literal stamps read off
GNU 9.4 with TZ pinned to Asia/Tehran, a zone whose offset differs by an hour
between its two periods, and each test pins and restores TZ so the result does
not depend on the host.

Breaking the fix again confirms they bite. With local_wall_clock returning the
uncorrected value:

test_parse_datetime_naive_string_is_local      1577923200 != 1577910600
test_parse_datetime_uses_the_offset_at_that_date 1623053350 != 1623037150
test_touch_create_with_d_option                  1669892401 != 1669879801

and test_parse_datetime_leaves_an_explicit_zone_alone still passed, which is the
point: the set discriminates rather than being uniformly red.

Comparing

48 invocations, nine absolute forms and seven relative ones, each under three
zones: UTC as a control where a naive read looks correct, Asia/Tehran, and
America/New_York for southern hemisphere rules. 13 of 48 before, 27 of 48 now,
and the 21 that still differ are exactly the seven relative forms in each zone:

yesterday, tomorrow, 2 hours ago, next week, last month, -1 day, +2 weeks

Relative forms are a separate piece of work, not a variant of this one. They
also embed the current clock, so their comparison has to be the delta from now
rather than the stamp.

v fmt -verify . passes, v run build.vsh builds all 76, and v test . reports
49 of 49.

The Windows CI job has been failing on main, not just on branches:

    touch/touch_windows.c.v:17:20: error: cannot use �oidptr as u32
    in argument 1 to C.SetFileTime

A HANDLE is a pointer. vlib's C.CreateFileW returns voidptr, and used to return
u32, so the u32 in this declaration stopped matching. touch is not in the
Windows ignore list, so the whole Windows build stopped with it.

One word, no behaviour change: the file did not compile, so there was no
behaviour to change.
A string with no zone in it names a local time. This read it as UTC, so every
such date landed hours off. Measured against GNU on this machine at +0330:

	touch -d '2020-01-02 03:04:05'
	  GNU     2020-01-02 03:04:05
	  before  2020-01-02 06:34:05

The offset cannot come from time.offset(), which reports the zone now. The same
zone wants a different offset depending on the date, and both of these were
measured rather than assumed:

	TZ=Asia/Tehran        2021-06-07 08:09:10  wants +0430, and +0330 now
	TZ=America/New_York   2020-01-02 00:00:00  wants -0500, and -0400 now

An earlier attempt here subtracted time.offset() and looked correct on every
case in the C locale; it was one hour out for June in Tehran, silently. The
offset in force at the date comes from zoneinfo's offset_at, which is what this
uses, applied twice so that a first guess landing on the far side of a DST
transition is corrected.

## The tests could not have caught this

Every -d test in touch_test.v computed its expectation with

	time.parse_iso8601(date).unix()

which is the very call the implementation makes. They agreed with the code by
construction, so they would have passed with the bug in place and would fail on
any machine that is not at UTC. The expectations are now literal stamps read off
GNU 9.4 with TZ pinned to Asia/Tehran, a zone whose offset differs by an hour
between its two periods, and each test pins and restores TZ so the result does
not depend on the host.

Breaking the fix again confirms they bite. With local_wall_clock returning the
uncorrected value:

	test_parse_datetime_naive_string_is_local      1577923200 != 1577910600
	test_parse_datetime_uses_the_offset_at_that_date 1623053350 != 1623037150
	test_touch_create_with_d_option                  1669892401 != 1669879801

and test_parse_datetime_leaves_an_explicit_zone_alone still passed, which is the
point: the set discriminates rather than being uniformly red.

## Comparing

48 invocations, nine absolute forms and seven relative ones, each under three
zones: UTC as a control where a naive read looks correct, Asia/Tehran, and
America/New_York for southern hemisphere rules. 13 of 48 before, 27 of 48 now,
and the 21 that still differ are exactly the seven relative forms in each zone:

	yesterday, tomorrow, 2 hours ago, next week, last month, -1 day, +2 weeks

Relative forms are a separate piece of work, not a variant of this one. They
also embed the current clock, so their comparison has to be the delta from now
rather than the stamp.

`v fmt -verify .` passes, `v run build.vsh` builds all 76, and `v test .` reports
49 of 49.
metif12 added a commit to metif12/coreutils that referenced this pull request Oct 3, 2026
Twenty three spellings were rejected, the rest of what GNU takes:

	March 1, 2020    1 March 2020    Mar 1 2020    Mon March 2 2020
	Monday, March 2, 2020             1/2/2020     2/1/2020
	1 March                          1/2/20       1/2/2020 03:04:05

Twenty three cases under two zones: 6 of 46 before, 46 of 46 now. The 6 that
already passed were the ones both binaries reject.

Every rule came from GNU. Two of them are the opposite of what the shape
suggests, and both were measured rather than reasoned about:

- A slashed date is **month first**, because that is the C locale's d_fmt:
  `1/2/2020` is 2 January and `2/1/2020` is 1 February. GNU does not fall back
  to day first when the first number cannot be a month, so `13/2/2020` is an
  error rather than 13 February.
- A weekday name is accepted and then **ignored**, even when it is the wrong
  one. 2020-03-01 was a Sunday, and Sun, Mon and Sat all give 2020-03-01.

Also measured, and implemented because they turned up while testing the above:

- Month names ignore case and an optional full stop: MARCH, march and Mar. alike.
- A missing year is the current one, so `1 March` is 1 March of this year.
- The day is checked against the month rather than rolled over: `31 April 2020`
  and `29 February 2021` are errors, while `29 February 2020` is fine.
- A day is required, so `March 2020` is an error.
- A time may follow, with or without seconds.
- `2020-01-02 03:04` and `2020-01-02 03` were being rejected as well, because
  parse_iso8601 insists on HH:MM:SS. The seconds are now supplied.

That last one is where the interesting ambiguity is. A bare four digit number is
HHMM when it stands alone, since `-d 2020` is 20:20 today, but a year once a
year has been taken, since `March 1 2020 0304` is 03:04 on 1 March 2020. Both are
covered by tests.

## Tests

Six more tests. Stamps read off GNU under TZ=Asia/Tehran; the two that cannot be
fixed numbers, a missing year and the bare-time base, are compared against the
same zone's calendar instead.

Making worded_date return none drops the matrix from 46 of 46 to 6 of 46 and
fails the suite at test_parse_datetime_month_names.

`v fmt -verify .` passes, `v run build.vsh` builds all 76, `v test .` reports
49 of 49, and src/touch builds with no notices.

Stacked on vlang#205, which carries vlang#204.
@metif12
metif12 force-pushed the fix/touch-datetime branch from f9917b7 to cdbafb5 Compare October 3, 2026 21:48
metif12 added a commit to metif12/coreutils that referenced this pull request Oct 3, 2026
On Windows, time.load_location('Local') answers with the zone the machine is set
to whatever the environment says. Measured on this host at +0330:

	TZ=UTC               touch -d '2020-01-02 03:04:05'  ->  1577934245
	TZ=Asia/Tehran       touch -d '2020-01-02 03:04:05'  ->  1577921645
	TZ=America/New_York  touch -d '2020-01-02 03:04:05'  ->  1577952245
	all three, before    touch -d '2020-01-02 03:04:05'  ->  1577921645

The first three are what GNU gives; the last is what this did for all of them.
Those are also the values this repository's own tests assert, so the tests passed
only because this machine is in the one zone they name, and every test file that
pins TZ would fail on a runner set to anything else.

TZ is now read first and 'Local' is the fallback, which is also what POSIX says
it means. A TZ naming no zone V can load is ignored rather than fatal, so an
unusual value falls back to the machine's own zone instead of turning every date
into an error.

local_today cannot use Time.local() for the same reason, and reads the date as
the epoch moved by the offset now in force and then taken as UTC fields.

## Tests

One new test, and it is the one that would have caught this: the same date read
in three zones inside a single process. A location cached from the first read
would fail it, which is the point.

src/touch is 24 of 24 and `v fmt -verify src/touch` passes.

Stacked on vlang#206, which carries vlang#205 and vlang#204.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant