From f5f1e1593bceab59e3166a04a4a7204c6a3c1a7f Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Sun, 9 Mar 2025 20:19:45 +0100 Subject: [PATCH] add parameter --time You can not generate a code for a specific time. Currently only UTC is supported. --- src/main.zig | 128 +++++++++++++++++++++++---------------------------- src/time.zig | 102 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 70 deletions(-) create mode 100644 src/time.zig diff --git a/src/main.zig b/src/main.zig index 87a721a..cda212b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -11,6 +11,8 @@ const ArrayList = std.ArrayList; const Base32 = @import("base32.zig").Base32; const Base32Error = @import("base32.zig").Base32Error; const options = @import("config"); +const Time = @import("time.zig").Time; +const TimeError = @import("time.zig").TimeError; // otpauth://totp/AWS+Dev?secret=47STA47VFCMMLLWOLHWO3KY7MYNC36MLCDTHOLIYKJCTTSSAMKVM7YA3VWT2AJEP&digits=6&icon=Amazon // otpauth://totp/Gitea%20%28git.lucares.de%29:andi?algorithm=SHA1&digits=6&issuer=Gitea&period=30&secret=MSP53Q672UJMSCLQVRCJKMMZKK7MWSMYFL77OQ24JBM65RZWY7F2Y45FMTNLYM36 @@ -33,6 +35,9 @@ pub fn main() !void { ArgumentError.AuthenticatorNotFound => { try std.io.getStdErr().writer().print("authenticator not found\n", .{}); }, + TimeError.ParsingError => { + try std.io.getStdErr().writer().print("Invalid time. The argument for the --time parameter must be a valid ISO 8601 date with second precision for timezone UTC. E.g. 2000-01-01T00:00:00\n", .{}); + }, else => |leftover_err| { try std.io.getStdErr().writer().print("{?}\n", .{leftover_err}); }, @@ -60,11 +65,13 @@ fn mainInternal() !void { } } else if (arg.show != null) { const authenticator = try getAuthenticator(allocator, arg.show.?, arg.config_location); - const code = try authenticator.code(allocator, std.time.timestamp); + const time = if (arg.time != null) try Time.parseIso8601(arg.time.?) else std.time.timestamp(); + const code = try authenticator.code(allocator, time); try std.io.getStdOut().writer().print("{s}\n", .{code}); } else if (arg.validateAuthenticator != null and arg.validateCode != null) { const authenticator = try getAuthenticator(allocator, arg.validateAuthenticator.?, arg.config_location); - const matches = try authenticator.validate(allocator, arg.validateCode.?, std.time.timestamp); + const time = if (arg.time != null) try Time.parseIso8601(arg.time.?) else std.time.timestamp(); + const matches = try authenticator.validate(allocator, arg.validateCode.?, time); if (!matches) { std.process.exit(1); } @@ -91,13 +98,14 @@ fn printHelp() void { \\ --fish Generate shell integration for fish. Usage: \\ zig-totp --fish > $__fish_config_dir/completions/zig-totp.fish \\ --help Show this help + \\ --time Set the time at which a code is computed. Time format is ISO 8601 with second precision for timezone UTC, e.g., 2000-01-01T00:00:00 \\ --version Print the version number \\ ; std.debug.print("{s}", .{msg}); } -const Args = struct { list: bool, show: ?[]const u8, config_location: []const u8, validateAuthenticator: ?[]const u8, validateCode: ?[]const u8, print_version: bool, show_help: bool }; +const Args = struct { list: bool, show: ?[]const u8, config_location: []const u8, validateAuthenticator: ?[]const u8, validateCode: ?[]const u8, print_version: bool, show_help: bool, time: ?[]const u8 }; const OtpAuthUrl = struct { name: []const u8, secretEncoded: []const u8, url: []const u8, period: u32, digits: u4, algorithm: []const u8 }; @@ -105,7 +113,7 @@ const Authenticator = struct { url: OtpAuthUrl, /// algorithm based on https://www.rfc-editor.org/rfc/rfc6238 - pub fn code(self: Authenticator, allocator: Allocator, timeFunc: *const fn () i64) ![]const u8 { + pub fn code(self: Authenticator, allocator: Allocator, time: i64) ![]const u8 { if (self.url.name.len > 0) {} const secret = try Base32.decodeU8(allocator, self.url.secretEncoded); @@ -114,7 +122,7 @@ const Authenticator = struct { debug("secret: {s}\n", .{secret}); } - const intervalNumber = @divTrunc(timeFunc(), self.url.period); + const intervalNumber = @divTrunc(time, self.url.period); var intervalAsU8Array: [8]u8 = undefined; std.mem.writePackedInt(i64, intervalAsU8Array[0..], 0, intervalNumber, .big); @@ -160,8 +168,8 @@ const Authenticator = struct { return result; } - fn validate(self: Authenticator, allocator: Allocator, expectedCode: []const u8, timeFunc: *const fn () i64) !bool { - const actualCode = try self.code(allocator, timeFunc); + fn validate(self: Authenticator, allocator: Allocator, expectedCode: []const u8, time: i64) !bool { + const actualCode = try self.code(allocator, time); defer allocator.free(actualCode); return eql(u8, actualCode, expectedCode); } @@ -193,7 +201,7 @@ fn zeroPad(allocator: Allocator, digits: u4, x: anytype) ![]u8 { const ArgumentError = error{ InvalidOtpAuthUrl, UnknownParameter, MissingConfigLocation, AuthenticatorNotFound, MissingAuthenticatorParam, FailedToOpenConfigFile, TooManyParsinErrors }; fn parseArgs(allocator: Allocator, args: []const []const u8) !Args { - var result = Args{ .list = false, .show = null, .config_location = try configLocation(allocator), .validateAuthenticator = null, .validateCode = null, .print_version = false, .show_help = false }; + var result = Args{ .list = false, .show = null, .config_location = try configLocation(allocator), .validateAuthenticator = null, .validateCode = null, .print_version = false, .show_help = false, .time = null }; var i: u17 = 1; while (i < args.len) : (i += 1) { const arg = args[i]; @@ -212,7 +220,7 @@ fn parseArgs(allocator: Allocator, args: []const []const u8) !Args { \\ then \\ COMPREPLY=($(compgen -A file -- "${COMP_WORDS[$COMP_CWORD]}" )) \\ else - \\ COMPREPLY=($(compgen -W "list show validate --bash --fish --help --config" -- "${COMP_WORDS[$COMP_CWORD]}" )) + \\ COMPREPLY=($(compgen -W "list show validate --bash --fish --help --time --config" -- "${COMP_WORDS[$COMP_CWORD]}" )) \\ fi \\} \\complete -F _zig_totp_completions zig-totp @@ -231,7 +239,8 @@ fn parseArgs(allocator: Allocator, args: []const []const u8) !Args { \\# parameters: \\complete -c zig-totp -l bash -d "generate bash integration code" \\complete -c zig-totp -l fish -d "generate fish integration code" - \\complete -c zig-totp -l config -d "define config location" --force-files --require-parameter -d 'override config location' + \\complete -c zig-totp -l config --force-files --require-parameter -d 'override config location' + \\complete -c zig-totp -l time --require-parameter -d 'set time' \\complete -c zig-totp -l help -d "show help" \\ \\# sub commands: @@ -250,7 +259,7 @@ fn parseArgs(allocator: Allocator, args: []const []const u8) !Args { } else if (eql(u8, "--config", arg)) { i += 1; if (i >= args.len) { - std.debug.print("expected a path after parameter '--config'\n see 'zig-totp' for help\n", .{}); + std.debug.print("expected a path after parameter '--config'\n see 'zig-totp --help' for help\n", .{}); return error.MissingConfigLocation; } result.config_location = args[i]; @@ -258,6 +267,13 @@ fn parseArgs(allocator: Allocator, args: []const []const u8) !Args { result.show_help = true; } else if (eql(u8, "--version", arg)) { result.print_version = true; + } else if (eql(u8, "--time", arg)) { + i += 1; + if (i >= args.len) { + std.debug.print("expected a path after parameter '--time'\n see 'zig-totp --help' for help\n", .{}); + return error.MissingConfigLocation; + } + result.time = args[i]; } else if (eql(u8, "list", arg)) { result.list = true; } else if (eql(u8, "show", arg)) { @@ -515,14 +531,14 @@ test "zero padding" { test "authenticator generate code with 6 digits" { const authenticator = Authenticator{ .url = OtpAuthUrl{ .name = "TestAuthenticator", .secretEncoded = "HJ5PX4IFQKD37HFNXLJYBAVD5G6NMMUOKUCDXJ4XTLUUBWSXRXEN5SR4MLAZA5M2", .url = "not needed", .period = 30, .digits = 6, .algorithm = "SHA1" } }; - const code = try authenticator.code(std.testing.allocator, _closure_return_1725695340); + const code = try authenticator.code(std.testing.allocator, 1725695340); defer std.testing.allocator.free(code); try std.testing.expectEqualStrings("218139", code); } test "authenticator generate code with 10 digits and zero padding" { const authenticator = Authenticator{ .url = OtpAuthUrl{ .name = "TestAuthenticator", .secretEncoded = "HJ5PX4IFQKD37HFNXLJYBAVD5G6NMMUOKUCDXJ4XTLUUBWSXRXEN5SR4MLAZA5M2", .url = "not needed", .period = 60, .digits = 10, .algorithm = "SHA1" } }; - const code = try authenticator.code(std.testing.allocator, _closure_return_1725695340); + const code = try authenticator.code(std.testing.allocator, 1725695340); defer std.testing.allocator.free(code); try std.testing.expectEqualStrings("0844221464", code); } @@ -534,117 +550,89 @@ test "testcases from https://www.rfc-editor.org/rfc/rfc6238#appendix-A" { const secretForSha512 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA"; // hex: "313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334353637383930" - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, _closure_return_59, "94287082"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 59, "94287082"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, _closure_return_1111111109, "07081804"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 1111111109, "07081804"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, _closure_return_1111111111, "14050471"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 1111111111, "14050471"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, _closure_return_1234567890, "89005924"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 1234567890, "89005924"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, _closure_return_2000000000, "69279037"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 2000000000, "69279037"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, _closure_return_20000000000, "65353130"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 20000000000, "65353130"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, _closure_return_59, "46119246"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, 59, "46119246"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, _closure_return_1111111109, "68084774"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, 1111111109, "68084774"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, _closure_return_1111111111, "67062674"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, 1111111111, "67062674"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, _closure_return_1234567890, "91819424"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, 1234567890, "91819424"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, _closure_return_2000000000, "90698825"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, 2000000000, "90698825"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, _closure_return_20000000000, "77737706"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha256, .url = "", .period = 30, .digits = 8, .algorithm = "SHA256" }, 20000000000, "77737706"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, _closure_return_59, "90693936"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 59, "90693936"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, _closure_return_1111111109, "25091201"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 1111111109, "25091201"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, _closure_return_1111111111, "99943326"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 1111111111, "99943326"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, _closure_return_1234567890, "93441116"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 1234567890, "93441116"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, _closure_return_2000000000, "38618901"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 2000000000, "38618901"); - try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, _closure_return_20000000000, "47863826"); + try testTOTP(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha512, .url = "", .period = 30, .digits = 8, .algorithm = "SHA512" }, 20000000000, "47863826"); } -fn testTOTP(otpAuthUrl: OtpAuthUrl, timeFunc: *const fn () i64, expected: []const u8) !void { +fn testTOTP(otpAuthUrl: OtpAuthUrl, time: i64, expected: []const u8) !void { const authenticator = Authenticator{ .url = otpAuthUrl }; - const code = try authenticator.code(std.testing.allocator, timeFunc); + const code = try authenticator.code(std.testing.allocator, time); defer std.testing.allocator.free(code); try std.testing.expectEqualStrings(expected, code); } -fn _closure_return_1725695340() i64 { - return 1725695340; -} - -fn _closure_return_59() i64 { - return 59; -} - -fn _closure_return_1111111109() i64 { - return 1111111109; -} - -fn _closure_return_1111111111() i64 { - return 1111111111; -} - -fn _closure_return_1234567890() i64 { - return 1234567890; -} - -fn _closure_return_2000000000() i64 { - return 2000000000; -} - -fn _closure_return_20000000000() i64 { - return 20000000000; -} - test "secret is not Base32 - invalid padding" { const authenticator = Authenticator{ .url = OtpAuthUrl{ .name = "", .secretEncoded = "H", .url = "", .period = 60, .digits = 6, .algorithm = "SHA1" } }; - const actualError = authenticator.code(std.testing.allocator, _closure_return_1725695340); + const actualError = authenticator.code(std.testing.allocator, 1725695340); try std.testing.expectError(Base32Error.InvalidPadding, actualError); } test "secret is not Base32 - invalid padding, input has too many bytes" { const authenticator = Authenticator{ .url = OtpAuthUrl{ .name = "", .secretEncoded = "HAX", .url = "", .period = 60, .digits = 6, .algorithm = "SHA1" } }; - const actualError = authenticator.code(std.testing.allocator, _closure_return_1725695340); + const actualError = authenticator.code(std.testing.allocator, 1725695340); try std.testing.expectError(Base32Error.InvalidPadding, actualError); } test "secret is not Base32 - invalid padding, trailing bits are not 0" { const authenticator = Authenticator{ .url = OtpAuthUrl{ .name = "", .secretEncoded = "HX", .url = "", .period = 60, .digits = 6, .algorithm = "SHA1" } }; - const actualError = authenticator.code(std.testing.allocator, _closure_return_1725695340); + const actualError = authenticator.code(std.testing.allocator, 1725695340); try std.testing.expectError(Base32Error.InvalidPadding, actualError); } test "secret is not Base32 - invalid character" { const authenticator = Authenticator{ .url = OtpAuthUrl{ .name = "", .secretEncoded = "0", .url = "", .period = 60, .digits = 6, .algorithm = "SHA1" } }; - const actualError = authenticator.code(std.testing.allocator, _closure_return_1725695340); + const actualError = authenticator.code(std.testing.allocator, 1725695340); try std.testing.expectError(Base32Error.InvalidCharacter, actualError); } test "testcases for 'validate'" { const secretForSha1 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // plain: "12345678901234567890" hex: "3132333435363738393031323334353637383930" - try testValidate(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, _closure_return_59, "94287082", true); + try testValidate(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 59, "94287082", true); - try testValidate(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, _closure_return_1111111109, "07081804", true); + try testValidate(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 1111111109, "07081804", true); - try testValidate(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, _closure_return_1111111109, "12345678", false); + try testValidate(OtpAuthUrl{ .name = "", .secretEncoded = secretForSha1, .url = "", .period = 30, .digits = 8, .algorithm = "SHA1" }, 1111111109, "12345678", false); } -fn testValidate(otpAuthUrl: OtpAuthUrl, timeFunc: *const fn () i64, expected: []const u8, expectedMatch: bool) !void { +fn testValidate(otpAuthUrl: OtpAuthUrl, time: i64, expected: []const u8, expectedMatch: bool) !void { const authenticator = Authenticator{ .url = otpAuthUrl }; - const match = try authenticator.validate(std.testing.allocator, expected, timeFunc); + const match = try authenticator.validate(std.testing.allocator, expected, time); try std.testing.expectEqual(expectedMatch, match); } diff --git a/src/time.zig b/src/time.zig new file mode 100644 index 0000000..4e53759 --- /dev/null +++ b/src/time.zig @@ -0,0 +1,102 @@ +const std = @import("std"); + +const expect = std.testing.expect; +const expectEqualStrings = std.testing.expectEqualStrings; +const expectEqual = std.testing.expectEqual; +const Allocator = std.mem.Allocator; + +pub const TimeError = error{ParsingError}; + +pub const Time = struct { + /// expecting a date in the format yyyy-MM-dd'T'HH:mm:ss + /// returning a unix timestamp + /// does not handle leap seconds + /// only handles UTC + pub fn parseIso8601(data: []const u8) !i64 { + const format = "nnnn-nn-nnTnn:nn:nn"; + // 0123456789012345678 + + // validate the format + if (data.len != 19) { + return TimeError.ParsingError; + } + + for (format, 0..) |formatChar, index| { + if (index >= data.len) { + return TimeError.ParsingError; + } + + const c = data[index]; + switch (formatChar) { + 'n' => { + if (!isDigit(c)) return TimeError.ParsingError; + }, + else => { + if (formatChar != c) return TimeError.ParsingError; + }, + } + } + + const year = try std.fmt.parseInt(i64, data[0..4], 10); + const month = try std.fmt.parseInt(i64, data[5..7], 10); + const day = try std.fmt.parseInt(i64, data[8..10], 10); + const hour = try std.fmt.parseInt(i64, data[11..13], 10); + const minute = try std.fmt.parseInt(i64, data[14..16], 10); + const second = try std.fmt.parseInt(i64, data[17..19], 10); + + const unixTime: i64 = (year - 1970) * 365 * 24 * 3600 // + + leapDays(year, month) * 24 * 3600 // + + dayInYear(month, day) * 24 * 3600 // + + hour * 3600 // + + minute * 60 // + + second; + + return unixTime; + } + + fn isDigit(byte: u8) bool { + return byte >= '0' and byte <= '9'; + } + + fn dayInYear(month: i64, day: i64) i64 { + const daysPerMonth = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + var result: i64 = 0; + var i: u8 = 0; + while (i < (month - 1)) : (i += 1) { + result += daysPerMonth[i]; + } + result += day - 1; + return result; + } + fn leapDays(year: i64, month: i64) i64 { + var i: u64 = 1972; + var result: i64 = 0; + while (i < year) : (i += 1) { + if (@mod(i, 4) == 0 and (i % 100 != 0 or @mod(i, 400) == 0)) { + result += 1; + } + } + if (month > 2 and @mod(year, 4) == 0 and (@mod(year, 100) != 0 or @mod(year, 400) == 0)) { + result += 1; + } + return result; + } +}; + +test "parsing error 'x025-01-01T00:00:00'" { + try std.testing.expectError(TimeError.ParsingError, Time.parseIso8601("x025-01-01T00:00:00")); +} + +test "parsing error '2025-01-01'" { + try std.testing.expectError(TimeError.ParsingError, Time.parseIso8601("2025-01-01T")); +} + +//test "parsing validation successfull" { +// const y = try Time.parseIso8601("2025-12-13T01:02:03"); +// try expectEqual(12, y); +//} + +test "parse 2025-02-18T18:37:45, expecting 1739903865" { + const unixTime = try Time.parseIso8601("2025-02-18T18:37:45"); + try expectEqual(1739903865, unixTime); +}