cli: portable timezone detection (nearest-region §11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+92
−0
timezone.go+47 −0timezone_test.go+45 −0
diff --git a/timezone.go b/timezone.go| new file mode 100644 |
| index 000000000..5376c01c0 |
| --- /dev/null |
| +++ b/timezone.go |
| @@ -0,0 +1,47 @@ |
| +package main |
| + |
| +import ( |
| + "os" |
| + "regexp" |
| + "strings" |
| +) |
| + |
| +// tzName is the shape of an IANA zone name the console will accept (spec |
| +// §6.2 step 1). Anything else is not sent: the console then falls back to |
| +// the request's IP, then the home. |
| +var tzName = regexp.MustCompile(`^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+){0,2}$`) |
| + |
| +var notAZone = map[string]bool{"localtime": true, "posixrules": true, "Local": true} |
| + |
| +// localTimezone finds this machine's IANA zone name, portably (no build |
| +// tags; the 20-cross gate builds it for darwin, windows and linux-arm64): |
| +// $TZ, else the /etc/localtime symlink target, else /etc/timezone, else "". |
| +// Windows has none of these, so only a valid $TZ is ever sent there. |
| +func localTimezone(getenv func(string) string, readlink func(string) (string, error), readFile func(string) ([]byte, error)) string { |
| + if tz := clean(strings.TrimPrefix(strings.TrimSpace(getenv("TZ")), ":")); tz != "" { |
| + return tz |
| + } |
| + if target, err := readlink("/etc/localtime"); err == nil { |
| + if tz := clean(target); tz != "" { |
| + return tz |
| + } |
| + } |
| + if b, err := readFile("/etc/timezone"); err == nil { |
| + line, _, _ := strings.Cut(string(b), "\n") |
| + return clean(line) |
| + } |
| + return "" |
| +} |
| + |
| +func clean(s string) string { |
| + s = strings.TrimSpace(s) |
| + if _, after, ok := strings.Cut(s, "zoneinfo/"); ok { |
| + s = after |
| + } |
| + if s == "" || strings.HasPrefix(s, "/") || notAZone[s] || len(s) > 64 || !tzName.MatchString(s) { |
| + return "" |
| + } |
| + return s |
| +} |
| + |
| +func detectTimezone() string { return localTimezone(os.Getenv, os.Readlink, os.ReadFile) } |
diff --git a/timezone_test.go b/timezone_test.go| new file mode 100644 |
| index 000000000..daab65bf0 |
| --- /dev/null |
| +++ b/timezone_test.go |
| @@ -0,0 +1,45 @@ |
| +package main |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestLocalTimezone(t *testing.T) { |
| + noLink := func(string) (string, error) { return "", errors.New("no") } |
| + noFile := func(string) ([]byte, error) { return nil, errors.New("no") } |
| + env := func(v string) func(string) string { |
| + return func(k string) string { |
| + if k == "TZ" { |
| + return v |
| + } |
| + return "" |
| + } |
| + } |
| + cases := []struct { |
| + name string |
| + getenv func(string) string |
| + readlink func(string) (string, error) |
| + readFile func(string) ([]byte, error) |
| + want string |
| + }{ |
| + {"TZ plain", env("Europe/London"), noLink, noFile, "Europe/London"}, |
| + {"TZ colon", env(":Europe/London"), noLink, noFile, "Europe/London"}, |
| + {"TZ path", env("/usr/share/zoneinfo/Asia/Kolkata"), noLink, noFile, "Asia/Kolkata"}, |
| + {"localtime link (linux)", env(""), func(string) (string, error) { return "/usr/share/zoneinfo/Europe/Dublin", nil }, noFile, "Europe/Dublin"}, |
| + {"localtime link (macOS)", env(""), func(string) (string, error) { return "/var/db/timezone/zoneinfo/Australia/Sydney", nil }, noFile, "Australia/Sydney"}, |
| + {"etc/timezone", env(""), noLink, func(string) ([]byte, error) { return []byte("Europe/Paris\n"), nil }, "Europe/Paris"}, |
| + // Review Focus 1: real-machine noise is a clean name or nothing. |
| + {"etc/timezone padded", env(""), noLink, func(string) ([]byte, error) { return []byte(" America/New_York \r\n# trailing\n"), nil }, "America/New_York"}, |
| + {"TZ bare colon", env(":"), noLink, noFile, ""}, |
| + {"TZ localtime", env("localtime"), noLink, noFile, ""}, |
| + {"TZ posixrules path", env("/usr/share/zoneinfo/posixrules"), noLink, noFile, ""}, |
| + {"TZ POSIX rule string", env("EST5EDT,M3.2.0,M11.1.0"), noLink, noFile, ""}, |
| + {"nothing", env(""), noLink, noFile, ""}, |
| + } |
| + for _, c := range cases { |
| + if got := localTimezone(c.getenv, c.readlink, c.readFile); got != c.want { |
| + t.Errorf("%s: %q, want %q", c.name, got, c.want) |
| + } |
| + } |
| +} |