carlos / platform Public

cli: portable timezone detection (nearest-region §11)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e2b5d8fb0cc3075bac132d6cf01924091b3d171e parent 3159f30
2 files changed, +92 −0
  • timezone.go +47 −0
  • timezone_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)
+ }
+ }
+}