From ac63017091582c43ae2a307e4481a8e8f2afff72 Mon Sep 17 00:00:00 2001 From: Adam Bernot Date: Fri, 10 Jul 2026 12:06:56 -0700 Subject: [PATCH] feat: parse ISO 8601 durations Signed-off-by: Adam Bernot --- duration.go | 10 ++ duration_test.go | 14 +++ iso8601_duration.go | 239 +++++++++++++++++++++++++++++++++++ iso8601_duration_test.go | 262 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 525 insertions(+) create mode 100644 iso8601_duration.go create mode 100644 iso8601_duration_test.go diff --git a/duration.go b/duration.go index f2ab7ff..9664890 100644 --- a/duration.go +++ b/duration.go @@ -144,6 +144,16 @@ func (d *Duration) UnmarshalText(data []byte) error { // validation is performed func ParseDuration(s string) (time.Duration, error) { // NOTE: this code is largely inspired by the standard library. orig := s + + clean := strings.TrimSpace(s) + if clean != "" && (clean[0] == '-' || clean[0] == '+') { + clean = clean[1:] + } + // Check if it's an ISO 8601 duration + if len(clean) > 0 && clean[0] == 'P' { + return ParseISO8601Duration(s) + } + var d uint64 neg := false diff --git a/duration_test.go b/duration_test.go index 5052ee9..924e1b4 100644 --- a/duration_test.go +++ b/duration_test.go @@ -71,6 +71,20 @@ func TestIsDuration_Failed(t *testing.T) { assert.FalseT(t, e) } +func TestParseDuration_ISO8601(t *testing.T) { + d, err := ParseDuration("P1D") + require.NoError(t, err) + assert.EqualT(t, 24*time.Hour, d) + + d, err = ParseDuration("-PT1H30M") + require.NoError(t, err) + assert.EqualT(t, -(time.Hour + 30*time.Minute), d) + + // Validate IsDuration supports ISO8601 format + assert.TrueT(t, IsDuration("P1D")) + assert.TrueT(t, IsDuration("-PT1H30M")) +} + func testDurationSQLScanner(t *testing.T, dur time.Duration) { t.Helper() diff --git a/iso8601_duration.go b/iso8601_duration.go new file mode 100644 index 0000000..af3b956 --- /dev/null +++ b/iso8601_duration.go @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package strfmt + +import ( + "fmt" + "math" + "strings" + "time" +) + +const ( + hoursPerDay = 24 + daysPerWeek = 7 + daysPerMonth = 30 + daysPerYear = 365 + + base10 = 10 + maxScale = 1000000000000000000 // 1e18 + + maxUint64Div10 = math.MaxUint64 / 10 + maxUint64Mod10 = '0' + byte(math.MaxUint64%10) +) + +var ( + errEmptyDuration = fmt.Errorf("%w: empty duration", ErrFormat) + errInvalidPStart = fmt.Errorf("%w: invalid ISO 8601 duration: must start with P", ErrFormat) + errEmptyAfterP = fmt.Errorf("%w: invalid ISO 8601 duration: empty after P", ErrFormat) + errOverflow = fmt.Errorf("%w: numerical overflow", ErrFormat) + errFraction = fmt.Errorf("%w: decimal fraction is only allowed on the least significant unit", ErrFormat) + errEmptyTime = fmt.Errorf("%w: empty time part after T", ErrFormat) + errInvalidDec = fmt.Errorf("%w: invalid decimal number", ErrFormat) +) + +type durationParser struct { + s string + total uint64 + hasFraction bool + neg bool +} + +// ParseISO8601Duration parses an ISO 8601 duration string. +func ParseISO8601Duration(s string) (time.Duration, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, errEmptyDuration + } + + neg := false + if s[0] == '-' || s[0] == '+' { + neg = s[0] == '-' + s = s[1:] + } + + if len(s) == 0 || s[0] != 'P' { + return 0, errInvalidPStart + } + s = s[1:] // Consume 'P' + if s == "" { + return 0, errEmptyAfterP + } + + p := durationParser{ + s: s, + neg: neg, + } + + if err := p.parseDatePart(); err != nil { + return 0, err + } + + if err := p.parseTimePart(); err != nil { + return 0, err + } + + // If there is anything left in the string, it indicates invalid junk or out-of-order components + if len(p.s) > 0 { + return 0, fmt.Errorf("%w: unrecognized trailing character %q", ErrFormat, p.s[0]) + } + + // Final boundary check for the positive duration limits + const maxDurationVal = uint64(1 << 63) + if p.total > maxDurationVal-1 { + if neg && p.total == maxDurationVal { + return time.Duration(math.MinInt64), nil + } + return 0, errOverflow + } + + dur := time.Duration(p.total) + if neg { + dur = -dur + } + return dur, nil +} + +func (p *durationParser) parseDatePart() error { + // 1. Years + if err := p.parseField(daysPerYear*hoursPerDay*time.Hour, 'Y'); err != nil { + return err + } + // 2. Months (before T) + if err := p.parseField(daysPerMonth*hoursPerDay*time.Hour, 'M'); err != nil { + return err + } + // 3. Weeks + if err := p.parseField(daysPerWeek*hoursPerDay*time.Hour, 'W'); err != nil { + return err + } + // 4. Days + if err := p.parseField(hoursPerDay*time.Hour, 'D'); err != nil { + return err + } + return nil +} + +func (p *durationParser) parseTimePart() error { + if len(p.s) == 0 { + return nil + } + if p.s[0] != 'T' { + return fmt.Errorf("%w: unrecognized trailing character %q", ErrFormat, p.s[0]) + } + p.s = p.s[1:] // Consume 'T' + if len(p.s) == 0 { + return errEmptyTime + } + + // 1. Hours + if err := p.parseField(time.Hour, 'H'); err != nil { + return err + } + // 2. Minutes (after T) + if err := p.parseField(time.Minute, 'M'); err != nil { + return err + } + // 3. Seconds + if err := p.parseField(time.Second, 'S'); err != nil { + return err + } + return nil +} + +func (p *durationParser) parseField(unit time.Duration, suffix byte) error { + if err := p.parseAndAccumulate(unit, suffix); err != nil { + return err + } + if p.hasFraction && len(p.s) > 0 { + return errFraction + } + return nil +} + +func (p *durationParser) parseAndAccumulate(unit time.Duration, suffix byte) error { + val, nextS, hasFraction, err := parseOptionalField(p.s, unit, suffix) + if err != nil { + return err + } + p.s = nextS + p.hasFraction = hasFraction + + if val == 0 { + return nil + } + + const maxVal = uint64(1 << 63) + if val > maxVal || p.total > maxVal-val { + // Special case: time.MinDuration (-1 << 63) is allowed if negative + if p.neg && p.total+val == maxVal { + p.total += val + return nil + } + return errOverflow + } + + p.total += val + return nil +} + +// parseOptionalField parses the number and trailing suffix unit in a single pass. +// It returns whether a decimal fraction was parsed (hasFraction). +func parseOptionalField(s string, unit time.Duration, suffix byte) (uint64, string, bool, error) { + i := 0 + var v uint64 + var f uint64 + var scale uint64 = 1 + hasDot := false + +loop: + for i < len(s) { + c := s[i] + switch { + case '0' <= c && c <= '9': + if !hasDot { + // Integer part overflow check - strict 10^18 boundary check + if v > maxUint64Div10 || (v == maxUint64Div10 && c > maxUint64Mod10) { + return 0, "", false, errOverflow + } + v = v*base10 + uint64(c-'0') + } else if scale < maxScale { + f = f*base10 + uint64(c-'0') + scale *= base10 + } + i++ + case c == '.' || c == ',': // Support both dot and comma as decimal separator + if hasDot { + return 0, "", false, errInvalidDec + } + hasDot = true + i++ + default: + break loop + } + } + + if i == 0 { + return 0, s, false, nil // No digits found: skip field + } + if i >= len(s) || s[i] != suffix { + return 0, s, false, nil // Suffix doesn't match: skip field + } + + //nolint:gosec // unit is a positive time.Duration, conversion to uint64 is safe + u := uint64(unit) + + // Multiplication overflow check to prevent silent wrap-around + if v > 0 && u > math.MaxUint64/v { + return 0, "", false, errOverflow + } + + // Calculate total value in nanoseconds + val := v * u + if f > 0 { + // Use float64 multiplication for fractional calculation to prevent scale overflow + val += uint64(float64(f) * (float64(u) / float64(scale))) + } + return val, s[i+1:], hasDot, nil // Consume digits and suffix +} diff --git a/iso8601_duration_test.go b/iso8601_duration_test.go new file mode 100644 index 0000000..fb42c5e --- /dev/null +++ b/iso8601_duration_test.go @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package strfmt + +import ( + "errors" + "math" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestParseISO8601Duration_Success(t *testing.T) { + testCases := []struct { + input string + expected time.Duration + }{ + // Years + {"P1Y", 365 * 24 * time.Hour}, + {"P2Y", 2 * 365 * 24 * time.Hour}, + // Months + {"P1M", 30 * 24 * time.Hour}, + {"P3M", 3 * 30 * 24 * time.Hour}, + // Weeks + {"P1W", 7 * 24 * time.Hour}, + {"P4W", 4 * 7 * 24 * time.Hour}, + // Days + {"P1D", 24 * time.Hour}, + {"P5D", 5 * 24 * time.Hour}, + // Hours + {"PT1H", time.Hour}, + {"PT12H", 12 * time.Hour}, + // Minutes + {"PT1M", time.Minute}, + {"PT45M", 45 * time.Minute}, + // Seconds + {"PT1S", time.Second}, + {"PT30S", 30 * time.Second}, + + // Combinations + {"P1Y2M3W4DT5H6M7S", (1*365+2*30+3*7+4)*24*time.Hour + 5*time.Hour + 6*time.Minute + 7*time.Second}, + {"P1DT1H", 25 * time.Hour}, + {"PT1H30M", time.Hour + 30*time.Minute}, + {"P1YT1S", 365*24*time.Hour + time.Second}, + + // Signs + {"+P1D", 24 * time.Hour}, + {"-P1D", -24 * time.Hour}, + {"-PT1H30M", -(time.Hour + 30*time.Minute)}, + + // Decimal Fractions (dot and comma) + {"PT1.5S", time.Second + 500*time.Millisecond}, + {"PT1,5S", time.Second + 500*time.Millisecond}, + {"P1.5D", 36 * time.Hour}, + {"P1,5D", 36 * time.Hour}, + {"PT0.5M", 30 * time.Second}, + {"PT0.25H", 15 * time.Minute}, + {"PT0.001S", time.Millisecond}, + {"PT0.000001S", time.Microsecond}, + {"PT0.000000001S", time.Nanosecond}, + + // Extremely precise fractional digits + {"PT0.123456789S", 123456789 * time.Nanosecond}, + + // Boundary Limits (Max/Min Duration) + {"PT9223372036.854775807S", time.Duration(math.MaxInt64)}, + {"-PT9223372036.854775808S", time.Duration(math.MinInt64)}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + result, err := ParseISO8601Duration(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestParseISO8601Duration_Failure(t *testing.T) { + testCases := []struct { + name string + input string + expectedErr error + }{ + { + name: "Empty duration", + input: "", + expectedErr: errEmptyDuration, + }, + { + name: "Empty duration with spaces", + input: " ", + expectedErr: errEmptyDuration, + }, + { + name: "Missing P prefix", + input: "1Y", + expectedErr: errInvalidPStart, + }, + { + name: "Missing P prefix with time", + input: "T1H", + expectedErr: errInvalidPStart, + }, + { + name: "Empty after P", + input: "P", + expectedErr: errEmptyAfterP, + }, + { + name: "Empty after P with sign", + input: "-P", + expectedErr: errEmptyAfterP, + }, + { + name: "Empty after P with plus sign", + input: "+P", + expectedErr: errEmptyAfterP, + }, + { + name: "Empty time part after T", + input: "P1DT", + expectedErr: errEmptyTime, + }, + { + name: "Empty time part after T only", + input: "PT", + expectedErr: errEmptyTime, + }, + { + name: "Multiple decimal separators double dot", + input: "PT1.5.5S", + expectedErr: errInvalidDec, + }, + { + name: "Multiple decimal separators double comma", + input: "PT1,5,5S", + expectedErr: errInvalidDec, + }, + { + name: "Multiple decimal separators mixed", + input: "PT1.5,5S", + expectedErr: errInvalidDec, + }, + { + name: "Decimal fraction on non-least significant unit date", + input: "P1.5Y1M", + expectedErr: errFraction, + }, + { + name: "Decimal fraction on non-least significant unit month", + input: "P1Y1.5M1W", + expectedErr: errFraction, + }, + { + name: "Decimal fraction on non-least significant unit week", + input: "P1Y1M1.5W1D", + expectedErr: errFraction, + }, + { + name: "Decimal fraction on non-least significant unit day", + input: "P1.5DT1H", + expectedErr: errFraction, + }, + { + name: "Decimal fraction on non-least significant unit hour", + input: "PT1.5H30M", + expectedErr: errFraction, + }, + { + name: "Decimal fraction on non-least significant unit minute", + input: "PT1H1.5M30S", + expectedErr: errFraction, + }, + { + name: "Out of order units Y and M", + input: "P1M2Y", + expectedErr: ErrFormat, // unrecognized trailing character + }, + { + name: "Out of order units H and M", + input: "PT1M2H", + expectedErr: ErrFormat, + }, + { + name: "Out of order units date and time", + input: "PT1HP1D", + expectedErr: ErrFormat, + }, + { + name: "Unrecognized trailing character", + input: "P1YZ", + expectedErr: ErrFormat, + }, + { + name: "Missing T for time unit", + input: "P1D1H", + expectedErr: ErrFormat, + }, + { + name: "Internal spaces", + input: "P 1Y", + expectedErr: ErrFormat, + }, + { + name: "Internal spaces with T", + input: "P1Y T1H", + expectedErr: ErrFormat, + }, + { + name: "Integer overflow", + input: "P999999999999999999999999Y", + expectedErr: errOverflow, + }, + { + name: "Multiplication overflow in parseOptionalField", + input: "PT20000000000S", + expectedErr: errOverflow, + }, + { + name: "Positive overflow max duration + 1", + input: "PT9223372036.854775808S", + expectedErr: errOverflow, + }, + { + name: "Negative overflow min duration - 1", + input: "-PT9223372036.854775809S", + expectedErr: errOverflow, + }, + { + name: "Total sum overflow", + input: "P100000000Y", + expectedErr: errOverflow, + }, + { + name: "Negative total sum overflow", + input: "-PT1388888H5000000000S", + expectedErr: errOverflow, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := ParseISO8601Duration(tc.input) + require.Error(t, err) + assert.True(t, errors.Is(err, tc.expectedErr), "expected error wrapped by %v, got %v", tc.expectedErr, err) + }) + } +} + +func TestParseISO8601Duration_PrecisionLimit(t *testing.T) { + // Values beyond 18 fractional digits are truncated because scale caps at maxScale. + // PT0.9223372036854775809S has 19 digits. + // scale caps at 10^18, so we get 0.922337203685477580 * 1s, which is 922337203685477580ns = 922337203ms 685us 477ns. + // Let's verify that it parses and is truncated exactly to 922337203685477580ns. + d, err := ParseISO8601Duration("PT0.9223372036854775809S") + require.NoError(t, err) + assert.Equal(t, 922337203*time.Nanosecond, d) +}