diff --git a/pkg/aqtime/aqtime.go b/pkg/aqtime/aqtime.go index 223b8426..b71809d2 100644 --- a/pkg/aqtime/aqtime.go +++ b/pkg/aqtime/aqtime.go @@ -7,10 +7,10 @@ import ( "time" ) +// RE matches the canonical internal format: 2006-01-02T15:04:05.000Z +// It also accepts the file-safe variant with dashes/dots swapped, for backward compat. var RE *regexp.Regexp -var Pattern string = `^(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)(?:[:-])(\d\d)(?:[:-])(\d\d)(?:[.-])(\d\d\d)Z$` - -type AQTime string +var Pattern string = `(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d)(?:[:-])(\d\d)(?:[:-])(\d\d)(?:[.-])(\d\d\d)Z` func init() { RE = regexp.MustCompile(fmt.Sprintf(`^%s$`, Pattern)) @@ -18,6 +18,8 @@ func init() { var fstr = "2006-01-02T15:04:05.000Z" +type AQTime string + // return a consistently formatted timestamp func FromMillis(ms int64) AQTime { return AQTime(time.UnixMilli(ms).UTC().Format(fstr)) @@ -29,11 +31,32 @@ func FromSec(sec int64) AQTime { } func FromString(str string) (AQTime, error) { - bits := RE.FindStringSubmatch(str) - if bits == nil { - return "", fmt.Errorf("bad time format, expected=%s got=%s", fstr, str) + // Reject -00:00 (valid RFC 3339 but disallowed by ATProto) + if strings.HasSuffix(str, "-00:00") { + return "", fmt.Errorf("bad time format, -00:00 timezone offset is not allowed, got=%s", str) + } + + t, err := time.Parse(time.RFC3339Nano, str) + if err != nil { + // Fall back to file-safe variant (e.g. 2024-09-13T18-10-17-090Z) + if bits := RE.FindStringSubmatch(str); bits != nil { + if bits[2] < "01" || bits[2] > "12" || bits[3] < "01" || bits[3] > "31" || + bits[4] > "23" || bits[5] > "59" || bits[6] > "60" { + return "", fmt.Errorf("bad time format, invalid date/time values in %s", str) + } + return AQTime(str), nil + } + return "", fmt.Errorf("bad time format: %w", err) } - return AQTime(str), nil + + // Reject if UTC normalization results in a negative year + utc := t.UTC() + if utc.Year() < 0 { + return "", fmt.Errorf("bad time format, datetime normalizes to negative year: %s", str) + } + + // Normalize to canonical UTC millisecond format + return AQTime(utc.Format(fstr)), nil } func FromTime(t time.Time) AQTime { diff --git a/pkg/aqtime/aqtime_test.go b/pkg/aqtime/aqtime_test.go index c68295d9..cfdaab51 100644 --- a/pkg/aqtime/aqtime_test.go +++ b/pkg/aqtime/aqtime_test.go @@ -35,14 +35,75 @@ func TestTimeParse(t *testing.T) { } } +// Valid ATProto datetime examples from the spec +// https://atproto.com/specs/lexicon#datetime +func TestATProtoValidCases(t *testing.T) { + tests := []struct { + input string + wantMs string // expected millisecond portion after normalization + wantHr string // expected hour after UTC normalization + wantMin string + }{ + {"1985-04-12T23:20:50.123Z", "123", "23", "20"}, + {"1985-04-12T23:20:50.123456Z", "123", "23", "20"}, + {"1985-04-12T23:20:50.120Z", "120", "23", "20"}, + {"1985-04-12T23:20:50.120000Z", "120", "23", "20"}, + {"0001-01-01T00:00:00.000Z", "000", "00", "00"}, + {"0000-01-01T00:00:00.000Z", "000", "00", "00"}, + {"1985-04-12T23:20:50.12345678912345Z", "123", "23", "20"}, + {"1985-04-12T23:20:50Z", "000", "23", "20"}, + {"1985-04-12T23:20:50.0Z", "000", "23", "20"}, + {"1985-04-12T23:20:50.123+00:00", "123", "23", "20"}, + {"1985-04-12T23:20:50.123-07:00", "123", "06", "20"}, // 23+7=30 -> next day 06:20 + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + aqt, err := FromString(tt.input) + require.NoError(t, err, "input: %s", tt.input) + _, _, _, hr, min, _, ms := aqt.Parts() + require.Equal(t, tt.wantMs, ms, "millis mismatch for %s", tt.input) + require.Equal(t, tt.wantHr, hr, "hour mismatch for %s", tt.input) + require.Equal(t, tt.wantMin, min, "minute mismatch for %s", tt.input) + }) + } +} + func TestBadCases(t *testing.T) { for _, str := range []string{ + // existing cases "prefix2024-09-13T18:10:17.090Z", "2024-09-13T18-10-17-090Zsuffix", "2024-09-13T18-10-17-090ZZZZ", "2024-09-13T18-10-17*090ZZZZ", + // ATProto spec invalid examples + "1985-04-12", + "1985-04-12T23:20Z", + "1985-04-12T23:20:5Z", + "1985-04-12T23:20:50.123", + "+001985-04-12T23:20:50.123Z", + "23:20:50.123Z", + "-1985-04-12T23:20:50.123Z", + "1985-4-12T23:20:50.123Z", + "01985-04-12T23:20:50.123Z", + "1985-04-12T23:20:50.123+00", + "1985-04-12T23:20:50.123+0000", + // ISO-8601 strict capitalization + "1985-04-12t23:20:50.123Z", + "1985-04-12T23:20:50.123z", + // RFC-3339, but not ISO-8601 + "1985-04-12T23:20:50.123-00:00", + "1985-04-12 23:20:50.123Z", + // timezone is required + "1985-04-12T23:20:50.123", + // syntax looks ok, but datetime is not valid + "1985-04-12T23:99:50.123Z", + "1985-00-12T23:20:50.123Z", + // ISO-8601, but normalizes to a negative time + "0000-01-01T00:00:00+01:00", } { - _, err := FromString(str) - require.Error(t, err) + t.Run(str, func(t *testing.T) { + _, err := FromString(str) + require.Error(t, err, "expected error for: %s", str) + }) } }