diff --git a/src/touch/datetime.v b/src/touch/datetime.v new file mode 100644 index 00000000..b152bb6c --- /dev/null +++ b/src/touch/datetime.v @@ -0,0 +1,347 @@ +import strconv +import time + +// A date string with no zone in it names a *local* time. That is the whole +// point of this file, and it is the one thing the port had wrong: a naive +// string was read as though it were UTC, so on this machine, at +0330, +// +// touch -d '2020-01-02 03:04:05' +// +// set the file to 06:34:05, three and a half hours late, and GNU sets 03:04: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 against GNU 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 +// +// So the offset has to be the one in force at the date being converted, which +// is what zoneinfo's offset_at answers. +const unresolved = 'no local time zone could be determined' + +// parse_datetime turns the argument of touch -d or -t into an absolute instant. +// compact says which flag it came from, because the compact stamp belongs to -t: +// GNU reads `-t 202001020304` as a date and `-d 202001020304` as something else +// entirely, landing in the year 2446. +// +// Everything this file recognises is rewritten into an ISO 8601 string first, +// so that one conversion at the end handles all of it. That keeps the zone logic +// in a single place instead of once per spelling. +fn parse_datetime(s string, compact bool) !i64 { + if s.starts_with('@') { + return epoch_seconds(s) + } + iso := to_iso8601(s, compact) + if iso != none { + return from_iso8601(iso) + } + return from_iso8601(s) +} + +// epoch_seconds reads @1600000000 and @1600000000.5. GNU keeps only the whole +// second, since that is all a file stamp can hold. +fn epoch_seconds(s string) !i64 { + body := s[1..] + mut whole := body + if idx := body.index('.') { + whole = body[..idx] + } + if whole.len == 0 { + return error('unable to parse date ${s}') + } + n := strconv.atoi64(whole) or { return error('unable to parse date ${s}') } + return n +} + +// to_iso8601 rewrites the spellings GNU accepts but parse_iso8601 does not, and +// returns none for anything it does not recognise so the caller can fall back. +fn to_iso8601(s string, compact bool) ?string { + if compact { + compact_iso := compact_stamp(s) + if compact_iso != none { + return compact_iso + } + } + zoned := split_zone_word(s) + if zoned != none { + // `10:00 UTC` and `10:00 GMT+3` name today at that time in that zone. + // Today is needed because a bare time means today at GNU, not 1970. + zoned_clock := clock_of(zoned.rest) + if zoned_clock == none { + return none + } + y, m, d := local_today() + stamp := '${y}-${pad2(m)}-${pad2(d)}' + + 'T${pad2(zoned_clock.hour)}:${pad2(zoned_clock.minute)}:${pad2(zoned_clock.second)}' + + zone_suffix(zoned.offset) + return stamp + } + basic := basic_date(s) + if basic != none { + // A bare YYYYMMDD is local midnight on that date. + return '${basic.year}-${pad2(basic.month)}-${pad2(basic.day)}T00:00:00' + } + bare := clock_of(s) + if bare != none { + // A bare time is today at that time. + y, m, d := local_today() + return '${y}-${pad2(m)}-${pad2(d)}' + + 'T${pad2(bare.hour)}:${pad2(bare.minute)}:${pad2(bare.second)}' + } + return none +} + +struct Clock { + hour int + minute int + second int +} + +// compact_stamp reads -t's [[CC]YY]MMDDhhmm[.ss]. GNU rejects fourteen digits, so +// the seconds only exist as the fractional part: +// +// 202001020304 -> 2020-01-02 03:04 +// 2001020304 -> 2020-01-02 03:04, the century supplied from the first two +// 20200102030405 -> invalid date format at GNU +fn compact_stamp(s string) ?string { + mut body := s + mut second := 0 + if idx := s.index('.') { + body = s[..idx] + frac := s[idx + 1..] + if frac.len != 2 || !all_digits(frac) { + return none + } + second = strconv.atoi(frac) or { return none } + } + if (body.len != 10 && body.len != 12) || !all_digits(body) { + return none + } + year := if body.len == 12 { + strconv.atoi(body[..4]) or { return none } + } else { + // Two digit year: GNU reads 20 as 2020, so the century is the one the + // number is nearest to, which for every year since 1970 is 2000 +. + 2000 + (strconv.atoi(body[..2]) or { return none }) + } + rest := body[body.len - 8..] + month := strconv.atoi(rest[..2]) or { return none } + day := strconv.atoi(rest[2..4]) or { return none } + hour := strconv.atoi(rest[4..6]) or { return none } + minute := strconv.atoi(rest[6..8]) or { return none } + if month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 + || second > 60 { + return none + } + return '${year}-${pad2(month)}-${pad2(day)}T${pad2(hour)}:${pad2(minute)}:${pad2(second)}' +} + +// split_zone_word pulls a trailing UTC or GMT, with an optional signed hour +// count, off the end of a bare time. The offset is seconds east of UTC, which is +// the opposite sign to the way the suffix is written. +struct Zoned { + offset int + rest string +} + +fn split_zone_word(s string) ?Zoned { + words := s.split(' ') + if words.len < 2 { + return none + } + last := words[words.len - 1] + mut base := '' + for prefix in ['GMT', 'UTC'] { + if last.starts_with(prefix) { + base = prefix + break + } + } + if base == '' { + return none + } + mut hours := 0 + zone_rest := last[base.len..] + if zone_rest.len > 0 { + if zone_rest[0] != `+` && zone_rest[0] != `-` { + return none + } + n := strconv.atoi(zone_rest[1..]) or { return none } + hours = if zone_rest[0] == `-` { -n } else { n } + } + // Drop the word and the space in front of it. The parentheses matter: V + // parses `s[:s.len - last.len]` as `s[:s.len]` followed by arithmetic. + cut := s.len - last.len - 1 + return Zoned{ + offset: hours * 3600 + rest: s[..cut] + } +} + +fn zone_suffix(offset int) string { + if offset == 0 { + return 'Z' + } + sign := if offset < 0 { '-' } else { '+' } + magnitude := if offset < 0 { -offset } else { offset } + return '${sign}${pad2(magnitude / 3600)}:${pad2((magnitude % 3600) / 60)}' +} + +// clock_of reads a bare time of day: 5 is 05:00, 0304 is 03:04, and 03:04 and +// 03:04:05 are themselves. Returns none for anything longer, which keeps a +// twelve digit compact stamp out of this path. +fn clock_of(s string) ?Clock { + if s.contains(':') { + parts := s.split(':') + if parts.len < 2 || parts.len > 3 { + return none + } + hour := strconv.atoi(parts[0]) or { return none } + minute := strconv.atoi(parts[1]) or { return none } + mut second := 0 + if parts.len == 3 { + second = strconv.atoi(parts[2]) or { return none } + } + if hour > 23 || minute > 59 || second > 60 { + return none + } + return Clock{ + hour: hour + minute: minute + second: second + } + } + if all_digits(s) && (s.len == 1 || s.len == 2) { + hour := strconv.atoi(s) or { return none } + if hour > 23 { + return none + } + return Clock{ + hour: hour + minute: 0 + second: 0 + } + } + if s.len == 4 && all_digits(s) { + hour := strconv.atoi(s[..2]) or { return none } + minute := strconv.atoi(s[2..]) or { return none } + if hour > 23 || minute > 59 { + return none + } + return Clock{ + hour: hour + minute: minute + second: 0 + } + } + return none +} + +// BasicDate is YYYYMMDD, which parse_iso8601 rejects because it expects the +// dashes. +struct BasicDate { + year int + month int + day int +} + +fn basic_date(s string) ?BasicDate { + if s.len != 8 || !all_digits(s) { + return none + } + date := BasicDate{ + year: strconv.atoi(s[..4]) or { return none } + month: strconv.atoi(s[4..6]) or { return none } + day: strconv.atoi(s[6..]) or { return none } + } + if date.month < 1 || date.month > 12 || date.day < 1 || date.day > 31 { + return none + } + return date +} + +fn local_today() (int, int, int) { + now := time.unix(time.now().unix()).local() + return now.year, now.month, now.day +} + +fn all_digits(s string) bool { + if s.len == 0 { + return false + } + for ch in s { + if ch < `0` || ch > `9` { + return false + } + } + return true +} + +fn pad2(n int) string { + return if n < 10 { '0${n}' } else { n.str() } +} + +// from_iso8601 resolves an ISO string, converting a zone-less one from local +// wall clock to an instant. +fn from_iso8601(s string) !i64 { + parsed := time.parse_iso8601(s) or { return error('unable to parse date ${s}') } + if has_explicit_zone(s) { + return parsed.unix() + } + loc := time.load_location('Local') or { return error(unresolved) } + return local_wall_clock(loc, parsed) +} + +// local_wall_clock converts a parsed Time whose calendar fields are local wall +// time into an absolute instant. +// +// parsed.unix() read those fields as if they were UTC, which is the value to +// correct. Subtracting the zone offset at *that* value is one step from the +// answer, and a second step settles it: when the first guess lands on the far +// side of a DST transition the offset it used was the wrong one. Two steps +// agree everywhere except inside the transition hour itself, where GNU also +// has to pick one and there is nothing to check against. +fn local_wall_clock(loc &time.Location, parsed time.Time) i64 { + naive := parsed.unix() + mut guess := naive + for _ in 0 .. 2 { + offset := loc.offset_at(guess) or { break } + next := naive - i64(offset) + if next == guess { + break + } + guess = next + } + return guess +} + +// has_explicit_zone reports whether the string says which zone it is in, which +// is the difference between an instant and a wall clock reading. +// +// The zone can only appear at the end of the time part, and the date has already +// been split off, so a sign inside the time token can only be a zone: times are +// written with ':' and '.', never with '+' or '-'. That makes the length and +// digit checks unnecessary, and getting them wrong is how `+03:30` came to be +// read as unzoned for one round. +fn has_explicit_zone(s string) bool { + mut tp := s.index_('T') + if tp == -1 { + tp = s.index_(' ') + if tp == -1 { + return false + } + } else { + // Step over the T, or the token below still starts with it. + tp++ + } + token := s[tp..].split(' ').last() + if token.len < 2 { + return false + } + if token.contains('+') || token.contains('-') { + return true + } + last := token[token.len - 1] + return last == `Z` || last == `z` +} diff --git a/src/touch/touch.v b/src/touch/touch.v index 92790ab5..01fffb62 100644 --- a/src/touch/touch.v +++ b/src/touch/touch.v @@ -112,10 +112,13 @@ fn get_date_time(args TouchArgs) (int, int) { dt := if args.date_arg.len > 0 { args.date_arg } else { args.time_arg } if dt.len > 0 { - date := time.parse_iso8601(dt) or { - common.exit_with_error_message(app_name, 'unable to parse date ${dt}') + // parse_datetime, not time.parse_iso8601: a string with no zone in it + // names a local time, and the offset that has to be applied is the one + // in force at that date, not the one in force now. See datetime.v. + stamp := parse_datetime(dt, args.time_arg.len > 0) or { + common.exit_with_error_message(app_name, err.msg()) } - return int(date.unix()), int(date.unix()) + return stamp, stamp } if args.reference.len > 0 { diff --git a/src/touch/touch_test.v b/src/touch/touch_test.v index c482e5de..eb289db6 100644 --- a/src/touch/touch_test.v +++ b/src/touch/touch_test.v @@ -3,6 +3,22 @@ module main import os import time +// The expectations in this file were read off GNU touch 9.4, not derived from +// the code under test. The previous version computed them with +// time.parse_iso8601(date).unix(), which is the very call the implementation +// makes, so it agreed with the code by construction and could not have caught +// the bug it existed to guard. Every stamp below is a literal measured under +// TZ=Asia/Tehran, a zone whose offset differs by a full hour between summer and +// winter, which is what makes it worth testing on. +// +// 2022-12-01T11:00:01 -> 1669879801 +// 2022-12-01T11:50:02 -> 1669882802 +// 2023-01-01T20:00:35 -> 1672590635 +// +// A zone with no rules available would make these meaningless, so the tests say +// so rather than quietly passing. +const tehran = 'Asia/Tehran' + fn temp_file_name() string { dir := os.temp_dir() file := '${dir}/t${time.ticks()}' @@ -17,6 +33,23 @@ fn pass() { assert true } +// pin_zone sets TZ for the duration of a test and hands back what to restore. +// The zone has to be forced: on a machine whose own zone happens to be UTC the +// whole bug this file guards is invisible. +fn pin_zone(zone string) string { + saved := os.getenv('TZ') + _ = os.setenv('TZ', zone, true) + return saved +} + +fn zone_available() bool { + time.load_location('Local') or { + p('no local zone information on this machine, skipping the ${tehran} assertions') + return false + } + return true +} + fn test_touch_one_file_no_options() { p(@METHOD) file := temp_file_name() @@ -49,62 +82,267 @@ fn test_touch_no_create_option() { pass() } +// A string with no zone in it names a local time. Read as UTC it lands three +// and a half hours late here, which is what these five used to do. +fn test_parse_datetime_naive_string_is_local() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + // A date with no time is local midnight, not UTC midnight. + assert parse_datetime('2020-01-02', false)! == 1_577_910_600 + assert parse_datetime('2020-01-02 03:04:05', false)! == 1_577_921_645 + assert parse_datetime('2020-01-02T03:04:05', false)! == 1_577_921_645 + assert parse_datetime('2020-02-29 12:00:00', false)! == 1_582_965_000 + assert parse_datetime('2025-01-01 00:00:00', false)! == 1_735_677_000 + pass() +} + +// The offset that applies is the one in force at that date, not the one in force +// now. Tehran is +0430 in June and +0330 in October, so subtracting the current +// offset puts this one an hour out: 08:09:10 - 4:30 = 03:39:10 UTC. +fn test_parse_datetime_uses_the_offset_at_that_date() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert parse_datetime('2021-06-07 08:09:10', false)! == 1_623_037_150 + // And the same zone in its other period, to show both are in play. + assert parse_datetime('2020-01-02 03:04:05', false)! == 1_577_921_645 + pass() +} + +// A string that says its zone is already an instant and must not be shifted. +fn test_parse_datetime_leaves_an_explicit_zone_alone() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert parse_datetime('2020-01-02T03:04:05Z', false)! == 1_577_934_245 + // +03:30 is the zone's own offset in January, so this lands where the naive + // spelling of the same wall clock does. It has to get there on its own. + assert parse_datetime('2020-01-02T03:04:05+03:30', false)! == 1_577_921_645 + pass() +} + +fn test_has_explicit_zone() { + p(@METHOD) + assert has_explicit_zone('2020-01-02T03:04:05Z') + assert has_explicit_zone('2020-01-02T03:04:05z') + assert has_explicit_zone('2020-01-02T03:04:05+03:30') + assert has_explicit_zone('2020-01-02T03:04:05-05:00') + assert has_explicit_zone('2020-01-02 03:04:05Z') + assert has_explicit_zone('2020-01-02 03:04:05+0330') + + assert !has_explicit_zone('2020-01-02 03:04:05') + assert !has_explicit_zone('2020-01-02T03:04:05') + // The date carries two hyphens and no time part at all. + assert !has_explicit_zone('2020-01-02') + assert !has_explicit_zone('2020-01-02 03:04:05.678') + pass() +} + +// @1600000000 is an instant already, so no zone logic applies and the fraction +// is dropped, since a file stamp holds whole seconds. Read off GNU: +// @1600000000 -> 1600000000 @0 -> 0 @1600000000.5 -> 1600000000 +fn test_parse_datetime_epoch() { + p(@METHOD) + assert parse_datetime('@1600000000', false)! == 1_600_000_000 + assert parse_datetime('@0', false)! == 0 + assert parse_datetime('@1600000000.5', false)! == 1_600_000_000 + pass() +} + +// -t's compact stamp is [[CC]YY]MMDDhhmm[.ss]. GNU rejects fourteen digits, so +// the seconds only exist as the fractional part, and a ten digit reading takes +// the century from the first two. All four were read off GNU under Tehran. +fn test_parse_datetime_compact_stamp() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + // The compact reading is the same wall clock as the spelled out one, and the + // two digit year resolves to 2020 rather than 1920. + spelled := parse_datetime('2020-01-02 03:04:00', false)! + assert parse_datetime('202001020304', true)! == spelled + assert parse_datetime('2001020304', true)! == spelled + // Fourteen digits is not a stamp. + assert_parse_fails('20200102030405', true) + // The compact reading belongs to -t only. Under -d GNU reads the same digits + // as something else entirely and lands in the year 2446, so -d must not + // quietly accept them as a date. + assert_parse_fails('202001020304', false) + pass() +} + +// A bare time means today at that time, not 1970. The date therefore has to come +// from the local zone, which is what local_today reads. The expectations are +// built from the same local date rather than hard coded, because "today" moves. +fn test_parse_datetime_bare_time_is_today() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + loc := time.load_location('Local') or { return } + today := time.unix(time.now().unix()).in(loc) or { return } + midnight := parse_datetime('00:00', false)! + day := parse_datetime('00:00:00', false)! + assert midnight == day + // 5 is 05:00, and 0304 is 03:04. + assert parse_datetime('5', false)! == midnight + 5 * 3600 + assert parse_datetime('0304', false)! == midnight + (3 * 3600 + 4 * 60) + assert parse_datetime('03:04', false)! == parse_datetime('0304', false)! + assert parse_datetime('03:04:05', false)! == parse_datetime('0304', false)! + 5 + // And it is today's date, not a fixed one: read the result back into the zone + // and compare the calendar fields with today. + read_back := time.unix(midnight).in(loc) or { return } + assert read_back.year == today.year + assert read_back.month == today.month + assert read_back.day == today.day + assert read_back.hour == 0 + pass() +} + +// A bare YYYYMMDD is local midnight on that date. Measured under Tehran: +// 20200102 -> 1577910600 +fn test_parse_datetime_basic_date() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + assert parse_datetime('20200102', false)! == 1_577_910_600 + assert parse_datetime('20200102', false)! == parse_datetime('2020-01-02', false)! + pass() +} + +// A zone word after a bare time names today at that time in that zone, so the +// suffix's sign is the opposite of what it looks like. Measured under Tehran, +// where the local offset is +0330: +// 10:00 UTC -> 2026-10-03 13:30 local +// 10:00 GMT+3 -> 2026-10-03 10:30 local +// 10:00 UTC-2 -> 2026-10-03 15:30 local +fn test_parse_datetime_zone_word() { + p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } + plain := parse_datetime('10:00', false)! + utc := parse_datetime('10:00 UTC', false)! + assert utc - plain == 3 * 3600 + 30 * 60 + assert parse_datetime('10:00 GMT', false)! == utc + // GMT+3 is three hours ahead of UTC, so it lands three hours earlier than UTC. + assert utc - parse_datetime('10:00 GMT+3', false)! == 3 * 3600 + // UTC-2 is two hours behind UTC. + assert parse_datetime('10:00 UTC-2', false)! - utc == 2 * 3600 + pass() +} + +fn assert_parse_fails(s string, compact bool) { + parse_datetime(s, compact) or { return } + assert false, 'expected ${s} to be rejected' +} + fn test_touch_create_with_d_option() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } file := temp_file_name() - date := '2022-12-01T11:00:01' - unix := time.parse_iso8601(date)!.unix() - touch(['touch', '-d', date, file]) + touch(['touch', '-d', '2022-12-01T11:00:01', file]) stat := os.lstat(file)! - assert stat.atime == unix - assert stat.mtime == unix + assert stat.atime == 1_669_879_801 + assert stat.mtime == 1_669_879_801 os.rm(file)! pass() } fn test_touch_create_with_a_d_option() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } file := temp_file_name() - date := '2022-12-01T11:00:01' - unix := time.parse_iso8601(date)!.unix() - touch(['touch', '-a', '-d', date, file]) + touch(['touch', '-a', '-d', '2022-12-01T11:00:01', file]) stat := os.lstat(file)! - assert stat.atime == unix - assert stat.mtime != unix + assert stat.atime == 1_669_879_801 + assert stat.mtime != 1_669_879_801 os.rm(file)! pass() } fn test_touch_create_with_m_d_option() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } file := temp_file_name() - date := '2022-12-01T11:00:01' - unix := time.parse_iso8601(date)!.unix() - touch(['touch', '-m', '-d', date, file]) + touch(['touch', '-m', '-d', '2022-12-01T11:00:01', file]) stat := os.lstat(file)! - assert stat.atime != unix - assert stat.mtime == unix + assert stat.atime != 1_669_879_801 + assert stat.mtime == 1_669_879_801 os.rm(file)! pass() } fn test_touch_with_reference_file() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } rfile := temp_file_name() - mdate := '2022-12-01T11:00:01' - mtime := time.parse_iso8601(mdate)!.unix() - touch(['touch', '-d', mdate, rfile]) - - adate := '2022-12-01T11:50:02' - atime := time.parse_iso8601(adate)!.unix() - touch(['touch', '-a', '-d', adate, rfile]) + touch(['touch', '-d', '2022-12-01T11:00:01', rfile]) + touch(['touch', '-a', '-d', '2022-12-01T11:50:02', rfile]) file := rfile + 'x' touch(['touch', '-r', rfile, file]) stat := os.lstat(file)! - assert stat.atime == atime - assert stat.mtime == mtime + assert stat.atime == 1_669_882_802 + assert stat.mtime == 1_669_879_801 os.rm(file)! os.rm(rfile)! @@ -113,15 +351,20 @@ fn test_touch_with_reference_file() { fn test_touch_no_reference_option() { p(@METHOD) + saved := pin_zone(tehran) + defer { + _ = os.setenv('TZ', saved, true) + } + if !zone_available() { + return + } file := temp_file_name() - fdate := '2022-12-01T11:00:01' - ftime := time.parse_iso8601(fdate)!.unix() - touch(['touch', '-d', fdate, file]) + touch(['touch', '-d', '2022-12-01T11:00:01', file]) // confirm correct start state stat := os.lstat(file)! - assert stat.atime == ftime - assert stat.mtime == ftime + assert stat.atime == 1_669_879_801 + assert stat.mtime == 1_669_879_801 if os.user_os() == 'windows' { eprintln('skip symlink checks on windows, they need administrative permissions') @@ -132,19 +375,17 @@ fn test_touch_no_reference_option() { os.symlink(file, link)! // touch the symlink - ldate := '2023-01-01T20:00:35' - ltime := time.parse_iso8601(ldate)!.unix() - touch(['touch', '-h', '-d', ldate, link]) + touch(['touch', '-h', '-d', '2023-01-01T20:00:35', link]) // check original file fstat := os.lstat(file)! - assert fstat.atime == ftime - assert fstat.mtime == ftime + assert fstat.atime == 1_669_879_801 + assert fstat.mtime == 1_669_879_801 // lstat does not 'follow' links lstat := os.lstat(link)! - assert lstat.atime == ltime - assert lstat.mtime == ltime + assert lstat.atime == 1_672_590_635 + assert lstat.mtime == 1_672_590_635 os.rm(link)! os.rm(file)! diff --git a/src/touch/touch_windows.c.v b/src/touch/touch_windows.c.v index b99e146c..19657b54 100644 --- a/src/touch/touch_windows.c.v +++ b/src/touch/touch_windows.c.v @@ -1,4 +1,7 @@ -fn C.SetFileTime(hfile u32, const_creation_time voidptr, const_access_time voidptr, const_modification_time voidptr) bool +// The handle is a voidptr because that is what vlib's C.CreateFileW returns; +// it used to return u32, and declaring the parameter as u32 is what made this +// file stop compiling on current V. A HANDLE is a pointer either way. +fn C.SetFileTime(hfile voidptr, const_creation_time voidptr, const_access_time voidptr, const_modification_time voidptr) bool fn lutime(path string, acctime int, modtime int) ! { creation_time := t2filetime(-1)