From 29f1054b74c4cbd6fb657207be51808e117c1032 Mon Sep 17 00:00:00 2001 From: Akseli Nurmio Date: Mon, 18 May 2026 10:11:33 +0300 Subject: [PATCH 1/7] add test cases for parser validation leaks --- duration_test.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/duration_test.go b/duration_test.go index 20e29e0..fd41140 100644 --- a/duration_test.go +++ b/duration_test.go @@ -95,6 +95,72 @@ func TestParse(t *testing.T) { want: nil, errorMatchFn: newMatchFn(ErrIncompleteExpr), }, + { + name: "double T", + args: args{d: "PTT1H"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, + { + name: "T between time designators", + args: args{d: "PT1HT1M"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, + { + name: "duplicate H designator", + args: args{d: "PT1H2H"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, + { + name: "duplicate Y designator", + args: args{d: "P1Y2Y"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, + { + name: "duplicate M designator in period", + args: args{d: "P1M2M"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, + { + name: "duplicate M designator in time", + args: args{d: "PT1M2M"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, + { + name: "time designators out of order M before H", + args: args{d: "PT1M1H"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, + { + name: "period designators out of order D before Y", + args: args{d: "P1D1Y"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, + { + name: "bare PT with no time components", + args: args{d: "PT"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, + { + name: "T at end after valid time component", + args: args{d: "PT2HT"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, + { + name: "non-ASCII digit", + args: args{d: "P٥Y"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From fce51f3b91c86a6ce10936394d654a8e5d315439 Mon Sep 17 00:00:00 2001 From: Akseli Nurmio Date: Mon, 18 May 2026 12:14:00 +0300 Subject: [PATCH 2/7] enforce stricter parse rules --- duration.go | 51 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/duration.go b/duration.go index 832ce5d..5b348ac 100644 --- a/duration.go +++ b/duration.go @@ -8,7 +8,6 @@ import ( "strconv" "strings" "time" - "unicode" ) // Duration holds all the smaller units that make up the duration @@ -51,10 +50,13 @@ var ( // Parse attempts to parse the given duration string into a *Duration, // if parsing fails an error is returned instead. func Parse(d string) (*Duration, error) { - state := parsingPeriod - duration := &Duration{} - num := "" - var err error + var ( + state = parsingPeriod + duration = &Duration{} + num string + err error + rank = 8 // designator order, strictly descending: Y=7 M=6 W=5 D=4 H=3 M=2 S=1 + ) switch { case strings.HasPrefix(d, "P"): // standard duration @@ -72,12 +74,15 @@ func Parse(d string) (*Duration, error) { return nil, ErrUnexpectedInput } case 'T': + if state == parsingTime || num != "" { + return nil, ErrUnexpectedInput + } state = parsingTime case 'Y': - if state != parsingPeriod { + if state != parsingPeriod || rank <= 7 { return nil, ErrUnexpectedInput } - + rank = 7 duration.Years, err = strconv.ParseFloat(num, 64) if err != nil { return nil, err @@ -85,12 +90,20 @@ func Parse(d string) (*Duration, error) { num = "" case 'M': if state == parsingPeriod { + if rank <= 6 { + return nil, ErrUnexpectedInput + } + rank = 6 duration.Months, err = strconv.ParseFloat(num, 64) if err != nil { return nil, err } num = "" - } else if state == parsingTime { + } else { + if rank <= 2 { + return nil, ErrUnexpectedInput + } + rank = 2 duration.Minutes, err = strconv.ParseFloat(num, 64) if err != nil { return nil, err @@ -98,57 +111,59 @@ func Parse(d string) (*Duration, error) { num = "" } case 'W': - if state != parsingPeriod { + if state != parsingPeriod || rank <= 5 { return nil, ErrUnexpectedInput } - + rank = 5 duration.Weeks, err = strconv.ParseFloat(num, 64) if err != nil { return nil, err } num = "" case 'D': - if state != parsingPeriod { + if state != parsingPeriod || rank <= 4 { return nil, ErrUnexpectedInput } - + rank = 4 duration.Days, err = strconv.ParseFloat(num, 64) if err != nil { return nil, err } num = "" case 'H': - if state != parsingTime { + if state != parsingTime || rank <= 3 { return nil, ErrUnexpectedInput } - + rank = 3 duration.Hours, err = strconv.ParseFloat(num, 64) if err != nil { return nil, err } num = "" case 'S': - if state != parsingTime { + if state != parsingTime || rank <= 1 { return nil, ErrUnexpectedInput } - + rank = 1 duration.Seconds, err = strconv.ParseFloat(num, 64) if err != nil { return nil, err } num = "" default: - if unicode.IsNumber(char) || char == '.' { + if (char >= '0' && char <= '9') || char == '.' { num += string(char) continue } - return nil, ErrUnexpectedInput } } if num != "" { return nil, ErrIncompleteExpr } + if state == parsingTime && rank > 3 { + return nil, ErrUnexpectedInput + } return duration, nil } From 6a21ee9eeeca0546ea92c9402a104de768fa1898 Mon Sep 17 00:00:00 2001 From: Akseli Nurmio Date: Mon, 18 May 2026 14:47:22 +0300 Subject: [PATCH 3/7] use ErrIncompleteExpr for bare PT --- duration.go | 2 +- duration_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/duration.go b/duration.go index 5b348ac..9587cbb 100644 --- a/duration.go +++ b/duration.go @@ -162,7 +162,7 @@ func Parse(d string) (*Duration, error) { return nil, ErrIncompleteExpr } if state == parsingTime && rank > 3 { - return nil, ErrUnexpectedInput + return nil, ErrIncompleteExpr } return duration, nil diff --git a/duration_test.go b/duration_test.go index fd41140..e317fd1 100644 --- a/duration_test.go +++ b/duration_test.go @@ -147,7 +147,7 @@ func TestParse(t *testing.T) { name: "bare PT with no time components", args: args{d: "PT"}, want: nil, - errorMatchFn: newMatchFn(ErrUnexpectedInput), + errorMatchFn: newMatchFn(ErrIncompleteExpr), }, { name: "T at end after valid time component", From 35f412b0822b32a44902d3c23d49a165b7880d77 Mon Sep 17 00:00:00 2001 From: Akseli Nurmio Date: Mon, 18 May 2026 14:51:00 +0300 Subject: [PATCH 4/7] add failing test for bare P --- duration_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/duration_test.go b/duration_test.go index e317fd1..592ffb0 100644 --- a/duration_test.go +++ b/duration_test.go @@ -83,6 +83,12 @@ func TestParse(t *testing.T) { }, errorMatchFn: noError, }, + { + name: "bare P with no components", + args: args{d: "P"}, + want: nil, + errorMatchFn: newMatchFn(ErrIncompleteExpr), + }, { name: "no unit after prefix P", args: args{d: "P6"}, From 2c09111a0c9d859b5ec93253974d891c2146c888 Mon Sep 17 00:00:00 2001 From: Akseli Nurmio Date: Mon, 18 May 2026 14:53:45 +0300 Subject: [PATCH 5/7] fix bare P returning nil error --- duration.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/duration.go b/duration.go index 9587cbb..0172f82 100644 --- a/duration.go +++ b/duration.go @@ -161,6 +161,9 @@ func Parse(d string) (*Duration, error) { if num != "" { return nil, ErrIncompleteExpr } + if state == parsingPeriod && rank == 8 { + return nil, ErrIncompleteExpr + } if state == parsingTime && rank > 3 { return nil, ErrIncompleteExpr } From e62e9fee64c4a39b68756f2c605db35383b64262 Mon Sep 17 00:00:00 2001 From: Akseli Nurmio Date: Mon, 18 May 2026 15:02:09 +0300 Subject: [PATCH 6/7] add failing tests for double P --- duration_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/duration_test.go b/duration_test.go index 592ffb0..4cd642a 100644 --- a/duration_test.go +++ b/duration_test.go @@ -101,6 +101,18 @@ func TestParse(t *testing.T) { want: nil, errorMatchFn: newMatchFn(ErrIncompleteExpr), }, + { + name: "double P at start", + args: args{d: "PP1D"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, + { + name: "trailing P in period section", + args: args{d: "P1DP"}, + want: nil, + errorMatchFn: newMatchFn(ErrUnexpectedInput), + }, { name: "double T", args: args{d: "PTT1H"}, From c72b628d19012b2fd744cbb63b15af0471a95f8f Mon Sep 17 00:00:00 2001 From: Akseli Nurmio Date: Mon, 18 May 2026 15:07:07 +0300 Subject: [PATCH 7/7] fix double P accepted as valid duration --- duration.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/duration.go b/duration.go index 0172f82..b690b50 100644 --- a/duration.go +++ b/duration.go @@ -60,19 +60,16 @@ func Parse(d string) (*Duration, error) { switch { case strings.HasPrefix(d, "P"): // standard duration + d = d[1:] case strings.HasPrefix(d, "-P"): // negative duration duration.Negative = true - d = strings.TrimPrefix(d, "-") // remove the negative sign + d = d[2:] default: return nil, ErrUnexpectedInput } for _, char := range d { switch char { - case 'P': - if state != parsingPeriod { - return nil, ErrUnexpectedInput - } case 'T': if state == parsingTime || num != "" { return nil, ErrUnexpectedInput