From a7608e4d1248fbf17c0ab818d14bee5d6cbd0e71 Mon Sep 17 00:00:00 2001 From: Alois Mahdal Date: Sun, 14 Jun 2026 19:52:55 +0200 Subject: [PATCH 1/6] Fix syntax error in README example --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2015aca..83cf589 100644 --- a/README.md +++ b/README.md @@ -86,12 +86,12 @@ pub fn main() !void { .iso8601, "2024-03-16T08:38:29.496-1200", &zeit.utc, - }); + ); _ = try zeit.instantFromText( .rfc3339, "2024-03-16T08:38:29.496706064-1200", &zeit.utc, - }); + ); } ``` From 5ed3c0d782ed3a128e78e33f1c8210bbae01e39e Mon Sep 17 00:00:00 2001 From: Alois Mahdal Date: Sun, 14 Jun 2026 19:53:00 +0200 Subject: [PATCH 2/6] Make README.md example output readable --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 83cf589..f5cd327 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ pub fn main() !void { const dt = now_local.time(); // Print it out - std.debug.print("{}", .{dt}); + std.debug.print("{}\n", .{dt}); // zeit.Time{ // .year = 2024, From 38c1c8613156aeb20b3034638135bfd89f2c750d Mon Sep 17 00:00:00 2001 From: Alois Mahdal Date: Sun, 14 Jun 2026 19:53:10 +0200 Subject: [PATCH 3/6] Use "juicy main" in README.md example --- README.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f5cd327..52196c3 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,14 @@ Or install another [tag](https://github.com/rockorager/zeit/tags) instead of mai const std = @import("std"); const zeit = @import("zeit"); -pub fn main() !void { - const allocator = std.heap.page_allocator; - var threaded = std.Io.Threaded.init(allocator, .{}); - defer threaded.deinit(); - const io = threaded.io(); +pub fn main(init: std.process.Init) !void { // Get a "now" instant in UTC. - const now = zeit.instant(.{.now = io}, &zeit.utc); + const now = zeit.instant(.{ .now = init.io }, &zeit.utc); // Load our local timezone. This needs an allocator. Optionally pass in a // zeit.EnvConfig to support TZ and TZDIR environment variables - const local = try zeit.local(allocator, io, .{}); + const local = try zeit.local(init.gpa, init.io, .{}); defer local.deinit(); // Convert our instant to a new timezone @@ -78,7 +74,7 @@ pub fn main() !void { // Load an arbitrary location using IANA location syntax. The location name // comes from an enum which will automatically map IANA location names to // Windows names, as needed. Pass an optional EnvConfig to support TZDIR - const vienna = try zeit.loadTimeZone(allocator, io, .@"Europe/Vienna", .{}); + const vienna = try zeit.loadTimeZone(init.gpa, init.io, .@"Europe/Vienna", .{}); defer vienna.deinit(); // Parse an Instant from an ISO8601 or RFC3339 string From cdffef90cbac7fef16a9b6e3263852dbd9431ed0 Mon Sep 17 00:00:00 2001 From: Alois Mahdal Date: Sun, 14 Jun 2026 19:53:17 +0200 Subject: [PATCH 4/6] Don't crash if TZ is set to empty string; use UTC instead POSIX[1] does not describe the empty string case: it's either starting with colon (implementation-defined) or at least 3 bytes for 'std' designation. I decided to follow example of glibc. From tzset(3): > If the TZ variable does appear in the environment, but its value > is empty, or its value cannot be interpreted using any of the formats > specified below, then Coordinated Universal Time (UTC) is used. A side note: this is actually a bit broken in glibc: the empty case is checked but sets the designation to "Universal", which is not a valid designation but results in what behaves as UTC. (Except that word "Universal", not "UTC" appears in as designation--just like with any "random" text that is not valid sub-path of zoneinfo.) [1]: https://pubs.opengroup.org/onlinepubs/9699919799.2016edition/basedefs/V1_chap08.html --- src/zeit.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/zeit.zig b/src/zeit.zig index 18f0c9f..075b585 100644 --- a/src/zeit.zig +++ b/src/zeit.zig @@ -43,6 +43,9 @@ pub fn local(alloc: std.mem.Allocator, io: std.Io, env: EnvConfig) !TimeZone { }, else => { if (env.tz) |tz| { + // Following glibc convention, which uses UTC if the TZ envvar + // is set, but the value is empty string. + if (tz.len == 0) return utc; // 'utc' being .fixed, utc.deinit() is a no-op return localFromEnv(alloc, io, tz, env); } From 376ae42c469fff22ab21e5477624f66695f1153f Mon Sep 17 00:00:00 2001 From: Alois Mahdal Date: Sun, 14 Jun 2026 20:30:26 +0200 Subject: [PATCH 5/6] Fix integer overflow when parsing TZ The `i -= 1;` line would crash if TZ started with a numeric character, which is not valid POSIX TZ, so it should return error.InvalidPosix. I've also added check that the designation must be at least 3 characters, as per POSIX[1]: > std and dst > Indicate no less than three, nor more than {TZNAME_MAX}, bytes that are > the designation for the standard (std) or the alternative (dst -such as > Daylight Savings Time) timezone. Only std is required; if dst is missing, > then the alternative time does not apply in this locale. I haven't added the TZNAME_MAX check as it would require larger changes and would probably aply to the quoted case as well. [1]: https://pubs.opengroup.org/onlinepubs/9699919799.2016edition/basedefs/V1_chap08.html --- src/timezone.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/timezone.zig b/src/timezone.zig index a2c7d1c..33cc2dc 100644 --- a/src/timezone.zig +++ b/src/timezone.zig @@ -175,10 +175,10 @@ pub const Posix = struct { state = .std_offset; }, else => { - i = std.mem.indexOfAnyPos(u8, str, i, "+-0123456789") orelse return error.InvalidPosix; - std_ = str[0..i]; - // backup one so this gets parsed as an offset - i -= 1; + const std_len = std.mem.indexOfNone(u8, str, std.ascii.letters) orelse return error.InvalidPosix; + if (std_len < 3) return error.InvalidPosix; // we don't check TZNAME_MAX + std_ = str[0..std_len]; + i = std_len - 1; state = .std_offset; }, } From f5bbaffa6de9c58f15f6e5ecf644b994e70ee8eb Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Sat, 20 Jun 2026 05:54:25 -0500 Subject: [PATCH 6/6] timezone: tighten POSIX TZ parsing The POSIX TZ parser accepted malformed input and could split fields incorrectly when offsets or names were missing. In particular, DST offsets at end of string updated the standard offset, and malformed strings such as UTC,foo or short DST names were accepted. Parse standard and daylight names consistently, require valid time fields, fix DST offset assignment, and add regressions for malformed TZ strings and empty TZ handling. --- src/timezone.zig | 349 +++++++++++++++++++---------------------------- src/zeit.zig | 11 ++ 2 files changed, 155 insertions(+), 205 deletions(-) diff --git a/src/timezone.zig b/src/timezone.zig index 33cc2dc..fd8e8cb 100644 --- a/src/timezone.zig +++ b/src/timezone.zig @@ -145,223 +145,142 @@ pub const Posix = struct { }; pub fn parse(str: []const u8) !Posix { - var std_: []const u8 = ""; - var std_offset: Seconds = 0; - var dst: ?[]const u8 = null; + const std_name = try parseName(str, 0); + const std_offset = try parseTime(str[std_name.end..]); + var i = std_name.end + std_offset.len; + + if (i == str.len) { + return .{ + .std = std_name.text, + .std_offset = std_offset.seconds, + }; + } + + if (str[i] == ',') return error.InvalidPosix; + + const dst_name = try parseName(str, i); + i = dst_name.end; + var dst_offset: ?Seconds = null; - var start: ?DSTSpec = null; - var end: ?DSTSpec = null; - - const State = enum { - std, - std_offset, - dst, - dst_offset, - start, - end, - }; + if (i < str.len and str[i] != ',') { + const offset = try parseTime(str[i..]); + dst_offset = offset.seconds; + i += offset.len; + } - var state: State = .std; - var i: usize = 0; - while (i < str.len) : (i += 1) { - switch (state) { - .std => { - switch (str[i]) { - '<' => { - // quoted. Consume until > - const end_qt = std.mem.indexOfScalar(u8, str[i..], '>') orelse return error.InvalidPosix; - std_ = str[i + 1 .. end_qt + i]; - i = end_qt; - state = .std_offset; - }, - else => { - const std_len = std.mem.indexOfNone(u8, str, std.ascii.letters) orelse return error.InvalidPosix; - if (std_len < 3) return error.InvalidPosix; // we don't check TZNAME_MAX - std_ = str[0..std_len]; - i = std_len - 1; - state = .std_offset; - }, - } - }, - .std_offset => { - const offset_start = i; - while (i < str.len) : (i += 1) { - switch (str[i]) { - '+', - '-', - ':', - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - => { - if (i == str.len - 1) - std_offset = parseTime(str[offset_start..]); - }, - else => { - std_offset = parseTime(str[offset_start..i]); - i -= 1; - state = .dst; - break; - }, - } - } - }, - .dst => { - switch (str[i]) { - '<' => { - // quoted. Consume until > - const dst_start = i + 1; - i = std.mem.indexOfScalarPos(u8, str, i, '>') orelse return error.InvalidPosix; - dst = str[dst_start..i]; - }, - else => { - const dst_start = i; - i += 1; - while (i < str.len) : (i += 1) { - switch (str[i]) { - ',' => { - dst = str[dst_start..i]; - state = .start; - break; - }, - '+', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => { - dst = str[dst_start..i]; - // backup one so this gets parsed as an offset - i -= 1; - state = .dst_offset; - break; - }, - else => { - if (i == str.len - 1) - dst = str[dst_start..]; - }, - } - } - }, - } - }, - .dst_offset => { - const offset_start = i; - while (i < str.len) : (i += 1) { - switch (str[i]) { - '+', - '-', - ':', - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - => { - if (i == str.len - 1) - std_offset = parseTime(str[offset_start..]); - }, - ',' => { - dst_offset = parseTime(str[offset_start..i]); - state = .start; - break; - }, - else => {}, - } - } - }, - .start => { - const comma_idx = std.mem.indexOfScalarPos(u8, str, i, ',') orelse return error.InvalidPosix; - if (std.mem.indexOfScalarPos(u8, str[0..comma_idx], i, '/')) |idx| { - start = try DSTSpec.parse(str[i..idx]); - switch (start.?) { - .julian => |*j| j.time = parseTime(str[idx + 1 .. comma_idx]), - .julian_leap => |*j| j.time = parseTime(str[idx + 1 .. comma_idx]), - .mwd => |*m| m.time = parseTime(str[idx + 1 .. comma_idx]), - } - } else { - start = try DSTSpec.parse(str[i..comma_idx]); - } - state = .end; - i = comma_idx; - }, - .end => { - if (std.mem.indexOfScalarPos(u8, str, i, '/')) |idx| { - end = try DSTSpec.parse(str[i..idx]); - switch (end.?) { - .julian => |*j| j.time = parseTime(str[idx + 1 ..]), - .julian_leap => |*j| j.time = parseTime(str[idx + 1 ..]), - .mwd => |*m| m.time = parseTime(str[idx + 1 ..]), - } - } else { - end = try DSTSpec.parse(str[i..]); - } - break; - }, - } + if (i == str.len) { + return .{ + .std = std_name.text, + .std_offset = std_offset.seconds, + .dst = dst_name.text, + .dst_offset = dst_offset, + }; } + + if (str[i] != ',') return error.InvalidPosix; + i += 1; + + const comma_idx = std.mem.indexOfScalarPos(u8, str, i, ',') orelse return error.InvalidPosix; + const start = try parseDSTSpecWithTime(str[i..comma_idx]); + const end = try parseDSTSpecWithTime(str[comma_idx + 1 ..]); + return .{ - .std = std_, - .std_offset = std_offset, - .dst = dst, + .std = std_name.text, + .std_offset = std_offset.seconds, + .dst = dst_name.text, .dst_offset = dst_offset, .start = start, .end = end, }; } - fn parseTime(str: []const u8) Seconds { - const State = enum { - hour, - minute, - second, - }; + const Name = struct { + text: []const u8, + end: usize, + }; + + fn parseName(str: []const u8, start: usize) !Name { + if (start >= str.len) return error.InvalidPosix; + + if (str[start] == '<') { + const end = std.mem.indexOfScalarPos(u8, str, start + 1, '>') orelse return error.InvalidPosix; + const text = str[start + 1 .. end]; + if (text.len < 3) return error.InvalidPosix; // we don't check TZNAME_MAX + return .{ .text = text, .end = end + 1 }; + } + + var end = start; + while (end < str.len and isAsciiAlpha(str[end])) : (end += 1) {} + const text = str[start..end]; + if (text.len < 3) return error.InvalidPosix; // we don't check TZNAME_MAX + return .{ .text = text, .end = end }; + } + + fn isAsciiAlpha(c: u8) bool { + return (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z'); + } + + const ParsedTime = struct { + seconds: Seconds, + len: usize, + }; + + fn parseDSTSpecWithTime(str: []const u8) !DSTSpec { + const slash_idx = std.mem.indexOfScalar(u8, str, '/'); + var spec = try DSTSpec.parse(if (slash_idx) |idx| str[0..idx] else str); + if (slash_idx) |idx| { + const time = try parseTime(str[idx + 1 ..]); + if (idx + 1 + time.len != str.len) return error.InvalidPosix; + switch (spec) { + .julian => |*j| j.time = time.seconds, + .julian_leap => |*j| j.time = time.seconds, + .mwd => |*m| m.time = time.seconds, + } + } + return spec; + } + + fn parseTime(str: []const u8) !ParsedTime { + if (str.len == 0) return error.InvalidPosix; + var is_neg = false; - var state: State = .hour; - var offset_h: i64 = 0; + var i: usize = 0; + switch (str[i]) { + '-' => { + is_neg = true; + i += 1; + }, + '+' => i += 1, + else => {}, + } + + const hour_start = i; + while (i < str.len and std.ascii.isDigit(str[i])) : (i += 1) {} + if (hour_start == i) return error.InvalidPosix; + const offset_h = try std.fmt.parseInt(i64, str[hour_start..i], 10); + var offset_m: i64 = 0; var offset_s: i64 = 0; - var i: usize = 0; - while (i < str.len) : (i += 1) { - switch (state) { - .hour => { - switch (str[i]) { - '-' => is_neg = true, - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => |d| { - offset_h = offset_h * 10 + @as(i64, d - '0'); - }, - ':' => state = .minute, - else => {}, - } - }, - .minute => { - switch (str[i]) { - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => |d| { - offset_m = offset_m * 10 + @as(i64, d - '0'); - }, - ':' => state = .second, - else => {}, - } - }, - .second => { - switch (str[i]) { - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => |d| { - offset_s = offset_s * 10 + @as(i64, d - '0'); - }, - else => {}, - } - }, + if (i < str.len and str[i] == ':') { + i += 1; + const minute_start = i; + while (i < str.len and std.ascii.isDigit(str[i])) : (i += 1) {} + if (minute_start == i) return error.InvalidPosix; + offset_m = try std.fmt.parseInt(i64, str[minute_start..i], 10); + if (offset_m > 59) return error.InvalidPosix; + + if (i < str.len and str[i] == ':') { + i += 1; + const second_start = i; + while (i < str.len and std.ascii.isDigit(str[i])) : (i += 1) {} + if (second_start == i) return error.InvalidPosix; + offset_s = try std.fmt.parseInt(i64, str[second_start..i], 10); + if (offset_s > 59) return error.InvalidPosix; } } + const offset = offset_h * s_per_hour + offset_m * s_per_min + offset_s; - return if (is_neg) -offset else offset; + return .{ .seconds = if (is_neg) -offset else offset, .len = i }; } /// reports true if the unix timestamp occurs when DST is in effect @@ -962,10 +881,13 @@ test "timezone.zig: Posix.isDST" { } test "timezone.zig: Posix.parseTime" { - try std.testing.expectEqual(0, Posix.parseTime("00:00:00")); - try std.testing.expectEqual(-3600, Posix.parseTime("-1")); - try std.testing.expectEqual(-7200, Posix.parseTime("-02:00:00")); - try std.testing.expectEqual(3660, Posix.parseTime("+1:01")); + try std.testing.expectEqual(0, (try Posix.parseTime("00:00:00")).seconds); + try std.testing.expectEqual(-3600, (try Posix.parseTime("-1")).seconds); + try std.testing.expectEqual(-7200, (try Posix.parseTime("-02:00:00")).seconds); + try std.testing.expectEqual(3660, (try Posix.parseTime("+1:01")).seconds); + try std.testing.expectError(error.InvalidPosix, Posix.parseTime("")); + try std.testing.expectError(error.InvalidPosix, Posix.parseTime("1:")); + try std.testing.expectError(error.InvalidPosix, Posix.parseTime("1:60")); } test "timezone.zig: Posix.parse" { @@ -999,6 +921,23 @@ test "timezone.zig: Posix.parse" { try std.testing.expectEqualStrings("UTC", t.std); try std.testing.expectEqual(-3661, t.std_offset); } + { + const t = try Posix.parse("UTC0EDT4"); + try std.testing.expectEqualStrings("UTC", t.std); + try std.testing.expectEqual(0, t.std_offset); + try std.testing.expectEqualStrings("EDT", t.dst.?); + try std.testing.expectEqual(14400, t.dst_offset.?); + } + { + const t = try Posix.parse("<-00>0"); + try std.testing.expectEqualStrings("-00", t.std); + try std.testing.expectEqual(0, t.std_offset); + } + try std.testing.expectError(error.InvalidPosix, Posix.parse("0")); + try std.testing.expectError(error.InvalidPosix, Posix.parse("UT0")); + try std.testing.expectError(error.InvalidPosix, Posix.parse("UTC,foo")); + try std.testing.expectError(error.InvalidPosix, Posix.parse("UTC/0")); + try std.testing.expectError(error.InvalidPosix, Posix.parse("UTC0ED")); { const t = try Posix.parse("CST1CDT"); try std.testing.expectEqualStrings("CST", t.std); diff --git a/src/zeit.zig b/src/zeit.zig index 075b585..6d050b8 100644 --- a/src/zeit.zig +++ b/src/zeit.zig @@ -1931,6 +1931,17 @@ test { _ = @import("timezone.zig"); } +test "local returns UTC when TZ is empty" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + const local_tz = try local(std.testing.allocator, std.testing.io, .{ .tz = "" }); + defer local_tz.deinit(); + + try std.testing.expectEqualStrings("UTC", local_tz.fixed.name); + try std.testing.expectEqual(0, local_tz.fixed.offset); + try std.testing.expectEqual(false, local_tz.fixed.is_dst); +} + test "fmtStrftime" { var buf: [128]u8 = undefined; const epoch = instant(.{ .unix_timestamp = 0 }, &utc);