diff --git a/cron/parser.go b/cron/parser.go index e853b46..a7618d7 100644 --- a/cron/parser.go +++ b/cron/parser.go @@ -23,6 +23,7 @@ import ( "strconv" "strings" "time" + "unicode" ) // ParseOption is a configuration option for creating a parser. Most options specify which @@ -115,9 +116,13 @@ func (p Parser) Parse(spec string) (Schedule, error) { if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") { var err error - i := strings.Index(spec, " ") + i := strings.IndexFunc(spec, unicode.IsSpace) eq := strings.Index(spec, "=") + if i == -1 { + return nil, fmt.Errorf("timezone prefix %s is not followed by a schedule", spec) + } + loc, err = time.LoadLocation(spec[eq+1 : i]) if err != nil { return nil, fmt.Errorf("provided bad location %s: %v", spec[eq+1:i], err) diff --git a/cron/parser_test.go b/cron/parser_test.go index 46e8692..6c8101b 100644 --- a/cron/parser_test.go +++ b/cron/parser_test.go @@ -143,6 +143,9 @@ func TestParseScheduleErrors(t *testing.T) { {"@unrecognized", "unrecognized descriptor"}, {"* * * *", "expected 5 to 6 fields"}, {"", "empty spec string"}, + {"TZ=UTC", "not followed by a schedule"}, + {"CRON_TZ=Asia/Tokyo", "not followed by a schedule"}, + {"TZ=", "not followed by a schedule"}, } for _, c := range tests { actual, err := secondParser.Parse(c.expr) @@ -168,6 +171,7 @@ func TestParseSchedule(t *testing.T) { {secondParser, "CRON_TZ=UTC 0 5 * * * *", every5min(time.UTC)}, {standardParser, "CRON_TZ=UTC 5 * * * *", every5min(time.UTC)}, {secondParser, "CRON_TZ=Asia/Tokyo 0 5 * * * *", every5min(tokyo)}, + {secondParser, "CRON_TZ=Asia/Tokyo\t0 5 * * * *", every5min(tokyo)}, {secondParser, "@every 5m", ConstantDelaySchedule{5 * time.Minute}}, {secondParser, "@every 5ms", ConstantDelaySchedule{5 * time.Millisecond}}, {secondParser, "@every 5ns", ConstantDelaySchedule{5 * time.Nanosecond}}, @@ -409,3 +413,54 @@ func annual(loc *time.Location) *SpecSchedule { Location: loc, } } + +// A timezone-prefixed schedule holds its local wall clock across a DST +// transition, shifting the underlying UTC instant by itself. +func TestParseScheduleTimezoneDST(t *testing.T) { + rome, err := time.LoadLocation("Europe/Rome") + if err != nil { + t.Fatalf("loading Europe/Rome: %v", err) + } + + sched, err := secondParser.Parse("CRON_TZ=Europe/Rome 0 0 9 * * *") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Europe/Rome leaves DST on 2026-10-25. + next := time.Date(2026, 10, 23, 12, 0, 0, 0, rome) + for _, exp := range []struct{ local, utc string }{ + {"2026-10-24T09:00:00+02:00", "2026-10-24T07:00:00Z"}, + {"2026-10-25T09:00:00+01:00", "2026-10-25T08:00:00Z"}, + {"2026-10-26T09:00:00+01:00", "2026-10-26T08:00:00Z"}, + } { + next = sched.Next(next) + if got := next.In(rome).Format(time.RFC3339); got != exp.local { + t.Errorf("local => expected %s, got %s", exp.local, got) + } + + if got := next.UTC().Format(time.RFC3339); got != exp.utc { + t.Errorf("utc => expected %s, got %s", exp.utc, got) + } + } +} + +// A constant delay has no wall clock for a timezone to apply to, so the prefix +// is accepted but has no effect. +func TestParseScheduleTimezoneIgnoredForEvery(t *testing.T) { + from := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + + withTZ, err := secondParser.Parse("CRON_TZ=Europe/Rome @every 1h") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + without, err := secondParser.Parse("@every 1h") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !withTZ.Next(from).Equal(without.Next(from)) { + t.Errorf("expected %v, got %v", without.Next(from), withTZ.Next(from)) + } +}