diff --git a/cron/doc.go b/cron/doc.go index bf25fb9..5d7aa00 100644 --- a/cron/doc.go +++ b/cron/doc.go @@ -182,6 +182,10 @@ For example: The prefix "TZ=(TIME ZONE)" is also supported for legacy compatibility. +A time zone prefix is not supported on "@every " schedules. These fire +at a fixed interval rather than at a wall-clock time, so there is no wall clock +for a time zone to apply to, and such a schedule is rejected at parse time. + Be aware that jobs scheduled during daylight-savings leap-ahead transitions will not be run! diff --git a/cron/parser.go b/cron/parser.go index e853b46..6c88f97 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,15 +116,28 @@ 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, " ") + prefixed := 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) } spec = strings.TrimSpace(spec[i:]) + + // An @every schedule fires at a fixed interval and has no wall clock for + // a timezone to apply to, so reject the pair rather than silently + // dropping the location. + if strings.HasPrefix(spec, "@every ") { + return nil, fmt.Errorf("timezone is not supported for @every schedules: %s", prefixed) + } } // Handle named schedules (descriptors), if configured diff --git a/cron/parser_test.go b/cron/parser_test.go index 46e8692..9be0b8f 100644 --- a/cron/parser_test.go +++ b/cron/parser_test.go @@ -143,6 +143,11 @@ 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"}, + {"CRON_TZ=Europe/Rome @every 1h", "not supported for @every"}, + {"TZ=Asia/Tokyo @every 30m", "not supported for @every"}, } for _, c := range tests { actual, err := secondParser.Parse(c.expr) @@ -158,6 +163,7 @@ func TestParseScheduleErrors(t *testing.T) { func TestParseSchedule(t *testing.T) { tokyo, _ := time.LoadLocation("Asia/Tokyo") + rome, _ := time.LoadLocation("Europe/Rome") entries := []struct { parser Parser expr string @@ -168,12 +174,14 @@ 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}}, {secondParser, "@midnight", midnight(time.Local)}, //nolint:gosmopolitan {secondParser, "TZ=UTC @midnight", midnight(time.UTC)}, {secondParser, "TZ=Asia/Tokyo @midnight", midnight(tokyo)}, + {secondParser, "CRON_TZ=Europe/Rome @daily", midnight(rome)}, {secondParser, "@yearly", annual(time.Local)}, //nolint:gosmopolitan {secondParser, "@annually", annual(time.Local)}, //nolint:gosmopolitan { @@ -409,3 +417,51 @@ 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 pair is +// rejected rather than silently dropping the location. +func TestParseScheduleTimezoneRejectedForEvery(t *testing.T) { + _, err := secondParser.Parse("CRON_TZ=Europe/Rome @every 1h") + if err == nil { + t.Error("expected an error, got nil") + } else if !strings.Contains(err.Error(), "CRON_TZ=Europe/Rome @every 1h") { + t.Errorf("error should include the original spec, got: %v", err) + } + + // An unprefixed @every is still valid. + _, err = secondParser.Parse("@every 1h") + if err != nil { + t.Errorf("unexpected error: %v", err) + } +}