diff --git a/.gitignore b/.gitignore index d8f4186..bbdffea 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .idea .env .mcp.json +go.work.sum diff --git a/README.md b/README.md index a0cf642..5edb0bc 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,6 @@ Golang support for string formats defined by JSON Schema and OpenAPI. ## Announcements -* **2025-12-19** : new community chat on discord - * a new discord community channel is available to be notified of changes and support users - * our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31** - -You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url] - -Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url] - * **2026-03-07** : v0.26.0 **dropped dependency to the mongodb driver** * mongodb users can still use this package without any change * however, we have frozen the back-compatible support for mongodb driver at v2.5.0 @@ -214,9 +206,6 @@ Maintainers can cut a new release by either: [doc-url]: https://goswagger.io/go-openapi [godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/strfmt [godoc-url]: http://pkg.go.dev/github.com/go-openapi/strfmt -[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png -[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM -[slack-url]: https://goswagger.slack.com/archives/C04R30YMU [discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue [discord-url]: https://discord.gg/FfnFYaC3k5 diff --git a/duration.go b/duration.go index b710bfb..f2ab7ff 100644 --- a/duration.go +++ b/duration.go @@ -7,11 +7,9 @@ import ( "database/sql/driver" "encoding/json" "fmt" - "math" - "regexp" - "strconv" "strings" "time" + "unicode" ) func init() { //nolint:gochecknoinits // registers duration format in the default registry @@ -22,34 +20,62 @@ func init() { //nolint:gochecknoinits // registers duration format in the defaul const ( hoursInDay = 24 daysInWeek = 7 + nanos = uint64(time.Nanosecond) + micros = uint64(time.Microsecond) + millis = uint64(time.Millisecond) + seconds = uint64(time.Second) + minutes = uint64(time.Minute) + hours = uint64(time.Hour) + days = uint64(hoursInDay * time.Hour) + weeks = uint64(hoursInDay * daysInWeek * time.Hour) + maxUint64 = uint64(1 << 63) ) +// timeMultiplier holds all supported aliases for duration units, including their plural form. +// //nolint:gochecknoglobals // package-level lookup tables for duration parsing -var ( - timeUnits = [][]string{ - {"ns", "nano"}, - {"us", "µs", "micro"}, - {"ms", "milli"}, - {"s", "sec"}, - {"m", "min"}, - {"h", "hr", "hour"}, - {"d", "day"}, - {"w", "wk", "week"}, - } - - timeMultiplier = map[string]time.Duration{ - "ns": time.Nanosecond, - "us": time.Microsecond, - "ms": time.Millisecond, - "s": time.Second, - "m": time.Minute, - "h": time.Hour, - "d": hoursInDay * time.Hour, - "w": hoursInDay * daysInWeek * time.Hour, - } - - durationMatcher = regexp.MustCompile(`^(((?:-\s?)?\d+)(\.\d+)?\s*([A-Za-zµ]+))`) -) +var timeMultiplier = map[string]uint64{ + "ns": nanos, + "nano": nanos, + "nanosecond": nanos, + "nanoseconds": nanos, + "nanos": nanos, + "us": micros, + "µs": micros, // U+00B5 = micro symbol + "μs": micros, // U+03BC = Greek letter mu + "micro": micros, + "micros": micros, + "microsecond": micros, + "microseconds": micros, + "ms": millis, + "milli": millis, + "millis": millis, + "millisecond": millis, + "milliseconds": millis, + "s": seconds, + "sec": seconds, + "secs": seconds, + "second": seconds, + "seconds": seconds, + "m": minutes, + "min": minutes, + "mins": minutes, + "minute": minutes, + "minutes": minutes, + "h": hours, + "hr": hours, + "hrs": hours, + "hour": hours, + "hours": hours, + "d": days, + "day": days, + "days": days, + "w": weeks, + "wk": weeks, + "wks": weeks, + "week": weeks, + "weeks": weeks, +} // IsDuration returns true if the provided string is a valid duration. func IsDuration(str string) bool { @@ -80,64 +106,157 @@ func (d *Duration) UnmarshalText(data []byte) error { // validation is performed return nil } -// ParseDuration parses a duration from a string, compatible with scala duration syntax. -func ParseDuration(cand string) (time.Duration, error) { - if dur, err := time.ParseDuration(cand); err == nil { - return dur, nil +// ParseDuration parses a duration from a string +// +// It is similar to [time.ParseDuration] but support additional units like days and weeks, +// additional abreviations for units and is more tolerant on the presence of blank spaces. +// +// A duration may be negative or fractional. +// +// # Differences with [time.ParseDuration] +// +// - more supported units and aliases (see below) +// - sign followed by blank space is tolerated +// - tolerates blanks between duration and unit (e.g. "300 ms") +// +// # Supported units +// +// Units may be specified using aliases or a plural form. +// +// - "ns", "nano", "nanosecond", "nanoseconds", "nanos" +// - "us", "µs" (U+00B5 = micro symbol), "μs" (U+03BC = Greek letter mu), "micro", "micros", "microsecond", "microseconds" +// - "ms", "milli", "millis", "millisecond", "milliseconds" +// - "s", "sec", "secs", "second", "seconds" +// - "m", "min", "mins", "minute", "minutes" +// - "h", "hr", "hrs", "hour", "hours" +// - "d", "day", "days" +// - "w", "wk", "wks", "week", "weeks" +// +// NOTE: inspired by scala duration syntax. +// +// # Examples +// +// "300ms", "-1.5h", "2h45m", +// ".5 week", +// "2 minutes 45 seconds". +// +//nolint:gocognit,gocyclo,cyclop // complexity is only slightly above the usual level, may be tolerated as it mimicks the stdlib. +func ParseDuration(s string) (time.Duration, error) { + // NOTE: this code is largely inspired by the standard library. + orig := s + var d uint64 + neg := false + + // Consume [-+]? + if s != "" { + c := s[0] + if c == '-' || c == '+' { + neg = c == '-' + s = s[1:] + } } - var dur time.Duration - ok := false - const expectGroups = 4 - for _, match := range durationMatcher.FindAllStringSubmatch(cand, -1) { - if len(match) < expectGroups { - continue + // Consume space + s = strings.TrimLeftFunc(s, unicode.IsSpace) + + // Special case: if all that is left is "0", this is zero. + if s == "0" { + return 0, nil + } + + if s == "" { + return 0, parseDurationError(orig, "empty duration") + } + + for s != "" { + var ( + v, f uint64 // integers before, after decimal point + scale float64 = 1 // value = v + f/scale + ) + s = strings.TrimLeftFunc(s, unicode.IsSpace) + + // The next character must be 0-9.] + if s[0] != '.' && ('0' > s[0] || s[0] > '9') { + return 0, parseDurationError(orig, fmt.Sprintf("expected a numerical value, but got %q", s[0])) + } + + // Consume integer part [0-9]* + pl := len(s) + var ok bool + v, s, ok = leadingInt(s) + if !ok { + return 0, parseDurationError(orig, "expected a leading integer part") } + pre := pl != len(s) // whether we consumed anything before a period - // remove possible leading - and spaces - value, negative := strings.CutPrefix(match[2], "-") + // Consume fractional part (\.[0-9]*)? + post := false + if s != "" && s[0] == '.' { + s = s[1:] + pl := len(s) + f, scale, s = leadingFraction(s) + post = pl != len(s) + } - // if the duration contains a decimal separator determine a divising factor - const neutral = 1.0 - divisor := neutral - decimal, hasDecimal := strings.CutPrefix(match[3], ".") - if hasDecimal { - divisor = math.Pow10(len(decimal)) - value += decimal // consider the value as an integer: will change units later on + if !pre && !post { + // no digits (e.g. ".s" or "-.s") + return 0, parseDurationError(orig, "expected digits") } - // if the string is a valid duration, parse it - factor, err := strconv.Atoi(strings.TrimSpace(value)) // converts string to int - if err != nil { - return 0, err + // Consume space. + s = strings.TrimLeftFunc(s, unicode.IsSpace) + + // Consume unit. + i := 0 + for ; i < len(s); i++ { + c := s[i] + if c == '.' || '0' <= c && c <= '9' || unicode.IsSpace(rune(c)) { + break + } } - if negative { - factor = -factor + if i == 0 { + return 0, parseDurationError(orig, "missing unit in duration") } - unit := strings.ToLower(strings.TrimSpace(match[4])) + u := s[:i] + s = s[i:] + unit, ok := timeMultiplier[u] + if !ok { + return 0, parseDurationError(orig, fmt.Sprintf("unknown unit %q in duration", u)) + } - for _, variants := range timeUnits { - last := len(variants) - 1 - multiplier := timeMultiplier[variants[0]] + if v > maxUint64/unit { + // overflow + return 0, parseDurationError(orig, "numerical overflow") + } - for i, variant := range variants { - if (last == i && strings.HasPrefix(unit, variant)) || strings.EqualFold(variant, unit) { - ok = true - if divisor != neutral { - multiplier = time.Duration(float64(multiplier) / divisor) // convert to duration only after having reduced the scale - } - dur += (time.Duration(factor) * multiplier) - } + v *= unit + if f > 0 { + // float64 is needed to be nanosecond accurate for fractions of hours. + // v >= 0 && (f*unit/scale) <= 3.6e+12 (ns/h, h is the largest unit) + v += uint64(float64(f) * (float64(unit) / scale)) + if v > maxUint64 { + // overflow + return 0, parseDurationError(orig, "numerical overflow") } } + + d += v + if d > maxUint64 { + return 0, parseDurationError(orig, "numerical overflow") + } } - if ok { - return dur, nil + if neg { + return -time.Duration(d), nil } - return 0, fmt.Errorf("unable to parse %s as duration: %w", cand, ErrFormat) + + if d > maxUint64-1 { + return 0, parseDurationError(orig, "numerical overflow") + } + + return time.Duration(d), nil } // Scan reads a Duration value from database driver type. @@ -204,3 +323,70 @@ func (d *Duration) DeepCopy() *Duration { d.DeepCopyInto(out) return out } + +func parseDurationError(s, msg string) error { + if msg == "" { + return fmt.Errorf("invalid duration: %s: %w", s, ErrFormat) + } + + return fmt.Errorf("invalid duration: %s: %s: %w", s, msg, ErrFormat) +} + +// leadingInt consumes the leading [0-9]* from s. +func leadingInt[bytes []byte | string](s bytes) (x uint64, rem bytes, ok bool) { //nolint:ireturn // false positive + i := 0 + for ; i < len(s); i++ { + c := s[i] + if c < '0' || c > '9' { + break + } + + if x > maxUint64/10 { // overflow + return 0, rem, false + } + + x = x*10 + uint64(c) - '0' + if x > maxUint64 { // overflow + return 0, rem, false + } + } + + return x, s[i:], true +} + +// leadingFraction consumes the leading [0-9]* from s. +// // +// It is used only for fractions, so it does not return an error on overflow, +// it just stops accumulating precision. +func leadingFraction(s string) (x uint64, scale float64, rem string) { + i := 0 + scale = 1 + overflow := false + for ; i < len(s); i++ { + c := s[i] + if c < '0' || c > '9' { + break + } + + if overflow { + continue + } + + if x > (maxUint64-1)/10 { + // It's possible for overflow to give a positive number, so take care. + overflow = true + continue + } + + y := x*10 + uint64(c) - '0' + if y > maxUint64 { + overflow = true + continue + } + + x = y + scale *= 10 + } + + return x, scale, s[i:] +} diff --git a/duration_test.go b/duration_test.go index c9d901c..5052ee9 100644 --- a/duration_test.go +++ b/duration_test.go @@ -156,6 +156,12 @@ func TestDurationParser(t *testing.T) { "1 hour": 1 * time.Hour, "1 day": 24 * time.Hour, "1 week": 7 * 24 * time.Hour, + + // parse composite forms + "1m45s": time.Minute + 45*time.Second, + "1 m45 s": time.Minute + 45*time.Second, + "1m 45s": time.Minute + 45*time.Second, + "1 minute 45 seconds": time.Minute + 45*time.Second, } for str, dur := range testcases { @@ -171,14 +177,160 @@ func TestDurationParser(t *testing.T) { } } +// TestDurationParser_EdgeCases covers ParseDuration branches that the happy-path +// tests don't exercise: the "0" shortcut, empty input left after a sign or +// spaces, and inputs that have a decimal point but no digit on either side. +func TestDurationParser_EdgeCases(t *testing.T) { + t.Run("zero shortcut returns 0 with no error", func(t *testing.T) { + for _, in := range []string{"0", "-0", "+0", "- 0", "+ 0"} { + input := in + t.Run(fmt.Sprintf("%q", input), func(t *testing.T) { + t.Parallel() + + d, err := ParseDuration(input) + require.NoError(t, err) + assert.EqualT(t, time.Duration(0), d) + }) + } + }) + + t.Run("empty payload after sign or spaces is rejected", func(t *testing.T) { + for _, in := range []string{"", "-", "+", " ", "- ", "+ "} { + input := in + t.Run(fmt.Sprintf("%q", input), func(t *testing.T) { + t.Parallel() + + _, err := ParseDuration(input) + require.Error(t, err) + }) + } + }) + + t.Run("decimal point without digits is rejected", func(t *testing.T) { + // A leading '.' passes the first numeric check but produces neither an + // integer nor a fractional part, exercising the "I dare you" branch. + for _, in := range []string{".s", ".h", ".d", "-.s", "+.h", "1m .s"} { + input := in + t.Run(input, func(t *testing.T) { + t.Parallel() + + _, err := ParseDuration(input) + require.Error(t, err) + }) + } + }) +} + +// TestDurationParser_Overflow covers every numerical-overflow branch in +// ParseDuration, leadingInt, and leadingFraction. +// +// The boundary values are derived from maxUint64 = 1<<63 (the magnitude of +// math.MinInt64). The fractional cases hinge on (1<<63 - 1)/10 = 922337203685477580. +func TestDurationParser_Overflow(t *testing.T) { + overflows := []struct { + name string + input string + }{ + { + name: "leadingInt overflow after multiply-add", + // 19 digits where the last one pushes x past 1<<63 even though + // x <= maxUint64/10 still held before the final multiply-add. + input: "9223372036854775809ns", + }, + { + name: "v*unit fits but adding fraction overflows", + // 2562047*hours = 9223369200000000000 (under 1<<63), then + // +0.9*hours = +3240000000000 pushes the sum past 1<<63. + input: "2562047.9h", + }, + { + name: "running total d exceeds maxUint64 across tokens", + // Each token alone fits (9e18 < 1<<63 ~= 9.223e18), but the sum + // (1.8e19) overflows in the in-loop d > maxUint64 check. + input: "9000000000000ms 9000000000000ms", + }, + { + name: "single positive value equals maxUint64", + // 1<<63 ns parses through leadingInt successfully, then fails the + // final d > maxUint64-1 check (only the negative form fits, as + // time.Duration(math.MinInt64)). + input: "9223372036854775808ns", + }, + } + + for _, tt := range overflows { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + d, err := ParseDuration(tc.input) + require.Errorf(t, err, "parsed %q as %v, expected overflow error", tc.input, d) + }) + } +} + +// TestDurationParser_FractionPrecisionLoss covers the three overflow-handling +// branches in leadingFraction. Unlike integer overflow, fractional overflow is +// not an error: per the helper's contract it stops accumulating precision and +// returns the partial value, so the parser still produces a valid duration — +// just truncated near the 18th fractional digit. +func TestDurationParser_FractionPrecisionLoss(t *testing.T) { + // All three inputs share the same first 18 fractional digits; precision + // caps there regardless of which overflow branch fires, so the parsed + // value is identical. + const truncated = time.Duration(922337203) // 0.922337203 * 1s, rounded down + + cases := []struct { + name string + input string + }{ + { + name: "y overflow on 19th digit", + // 19th digit '9' makes y = x*10+9 > 1<<63. + input: "0.9223372036854775809s", + }, + { + name: "x exceeds threshold on 20th digit", + // 19th digit '8' lands y exactly at 1<<63 (still accepted), + // 20th digit then trips the x > (1<<63-1)/10 guard. + input: "0.92233720368547758089s", + }, + { + name: "trailing digits skipped once overflow is set", + // 21st digit hits the early `if overflow` continue branch. + input: "0.922337203685477580891s", + }, + } + + for _, tt := range cases { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + d, err := ParseDuration(tc.input) + require.NoError(t, err) + assert.EqualT(t, truncated, d) + }) + } +} + +// TestDurationParser_NegativeMinDuration verifies that the smallest +// representable duration parses successfully even though the equivalent +// positive value overflows. +func TestDurationParser_NegativeMinDuration(t *testing.T) { + d, err := ParseDuration("-9223372036854775808ns") + require.NoError(t, err) + assert.EqualT(t, time.Duration(-1<<63), d) +} + func TestIsDuration_Caveats(t *testing.T) { // This works too e := IsDuration("45 weeks") assert.TrueT(t, e) - // This works too + // This no longer works e = IsDuration("45 weekz") - assert.TrueT(t, e) + assert.FalseT(t, e) // This works too e = IsDuration("12 hours") @@ -236,8 +388,8 @@ func TestIssue169FractionalDuration(t *testing.T) { ExpectError: true, }, { - Input: ".314159 d", - ExpectError: true, + Input: ".314159 d", + Expected: "7h32m23.3376s", }, { Input: "314159. d", diff --git a/go.work.sum b/go.work.sum deleted file mode 100644 index 33dac96..0000000 --- a/go.work.sum +++ /dev/null @@ -1,16 +0,0 @@ -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= -github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30 h1:BHT1/DKsYDGkUgQ2jmMaozVcdk+sVfz0+1ZJq4zkWgw= -github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= -github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=