From 7a6a4c79eafcde18509774dc2569d9d00ea8e4b8 Mon Sep 17 00:00:00 2001 From: Andrew McDermott Date: Wed, 16 Aug 2023 20:24:24 +0100 Subject: [PATCH] Enhance ParseDuration with configurable parsing options Introduce ParseDuration() as the primary and simplest entrypoint for parsing durations. By default it operates in "single-unit mode", meaning only one unit type is allowed per input string (e.g., "1d" as opposed to permitting "1d3h5m"). Assumes milliseconds as the default unit when none is specified. In addition, a new parser configuration is introduced which provides flexibility to specify a subset of permissible units. Allows custom defaults and parsing behaviors tailored for specific use cases. --- .../haproxy-timeout-checker.go | 41 +- duration_parser.go | 565 +++++++++++++----- duration_parser_test.go | 305 +++++++--- go.mod | 2 +- 4 files changed, 657 insertions(+), 256 deletions(-) diff --git a/cmd/haproxy-timeout-checker/haproxy-timeout-checker.go b/cmd/haproxy-timeout-checker/haproxy-timeout-checker.go index 93fec65..1b3fc42 100644 --- a/cmd/haproxy-timeout-checker/haproxy-timeout-checker.go +++ b/cmd/haproxy-timeout-checker/haproxy-timeout-checker.go @@ -3,32 +3,47 @@ package main import ( "fmt" "os" - "time" "github.com/frobware/haproxytime" ) -const HAProxyMaxTimeoutValue = 2147483647 * time.Millisecond +func printErrorWithPosition(input string, err error, position int) { + fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr, input) + fmt.Fprintf(os.Stderr, "%"+fmt.Sprint(position)+"s", "") + fmt.Fprintln(os.Stderr, "^") +} func main() { if len(os.Args) < 2 { - fmt.Println(`usage: `) + fmt.Println("usage: ") os.Exit(1) } - duration, position, err := haproxytime.ParseDuration(os.Args[1]) + parserCfg, err := haproxytime.NewParserConfig(haproxytime.UnitMillisecond, haproxytime.MultiUnitMode) if err != nil { - fmt.Fprintln(os.Stderr, os.Args[1]) - fmt.Fprintf(os.Stderr, "%"+fmt.Sprint(position)+"s", "") - fmt.Fprintln(os.Stderr, "^") - fmt.Fprintln(os.Stderr, "error:", err) - os.Exit(1) + fmt.Fprintf(os.Stderr, "error: failed to created parser configuration: %v\n", err) + os.Exit(2) } - if duration.Milliseconds() > HAProxyMaxTimeoutValue.Milliseconds() { - fmt.Fprintf(os.Stderr, "duration %vms exceeds HAProxy's maximum value of %vms\n", duration.Milliseconds(), HAProxyMaxTimeoutValue.Milliseconds()) - os.Exit(1) + duration, err := haproxytime.ParseDurationWithConfig(os.Args[1], parserCfg) + if err != nil { + switch actualErr := err.(type) { + case *haproxytime.SyntaxError: + printErrorWithPosition(os.Args[1], actualErr, actualErr.Position) + os.Exit(3) + case *haproxytime.OverflowError: + printErrorWithPosition(os.Args[1], actualErr, actualErr.Position) + os.Exit(4) + } + } + + if haproxytime.DurationExceedsMaxTimeout(duration) { + fmt.Fprintf(os.Stderr, "%vms exceeds HAProxy's maximum duration (%vms)\n", + duration.Milliseconds(), + haproxytime.MaxTimeoutInMilliseconds.Milliseconds()) + os.Exit(5) } - fmt.Println(duration.Milliseconds()) + fmt.Printf("%vms\n", duration.Milliseconds()) } diff --git a/duration_parser.go b/duration_parser.go index c8c4375..113d2c9 100644 --- a/duration_parser.go +++ b/duration_parser.go @@ -1,94 +1,271 @@ -// Package haproxytime provides a utility for parsing -// duration strings in a format similar to time.ParseDuration, with -// the additional capability of handling duration strings specifying a -// number of days (d). This functionality is not available in the -// built-in time.ParseDuration function. It also returns an error if -// any duration is negative. -// -// This package was primarily created for validating HAProxy timeout -// values. -// -// For example, an input of "2d 4h" would be parsed into a -// time.Duration representing two days and four hours. +// Package haproxytime offers utilities to parse duration strings +// similar to time.ParseDuration, but with extended functionality. In +// addition to the standard duration units, it also supports days +// (represented as "d"), which is unavailable in the built-in +// time.ParseDuration function. The package also ensures durations are +// non-negative and don't overflow a time.Duration value type. package haproxytime import ( "errors" "fmt" "strconv" + "strings" "time" "unicode" ) -var ( - // errParseDurationOverflow is triggered when a duration value - // exceeds the permissible maximum limit, leading to an - // overflow. - errParseDurationOverflow = fmt.Errorf("overflow") - - // errParseDurationUnderflow is triggered when a duration - // value falls below the acceptable minimum limit, resulting - // in an underflow. - errParseDurationUnderflow = fmt.Errorf("underflow") - - // strToUnit is a map that associates string representations - // of time units with their corresponding unit constants. It - // serves as a lookup table to convert string units to their - // respective durations. - strToUnit = map[string]unit{ - "d": day, - "h": hour, - "m": minute, - "s": second, - "ms": millisecond, - "us": microsecond, - } - - // unitToDuration is a map that correlates time duration units - // with their corresponding durations in time.Duration format. - unitToDuration = map[unit]time.Duration{ - day: 24 * time.Hour, - hour: time.Hour, - minute: time.Minute, - second: time.Second, - millisecond: time.Millisecond, - microsecond: time.Microsecond, - } +// MaxTimeoutInMilliseconds represents the maximum timeout value, +// equivalent to the maximum signed 32-bit integer value in +// milliseconds. It can be used as an upper limit when setting or +// comparing timeout durations to ensure they don't exceed this +// predefined threshold. +const MaxTimeoutInMilliseconds = 2147483647 * time.Millisecond + +// These constants represent different units of time used in the +// duration parsing process. They are ordered in decreasing magnitude, +// from UnitDay to UnitMicrosecond. The zero value of the unit type is +// reserved to represent an invalid unit. +const ( + UnitDay Unit = iota + 1 + UnitHour + UnitMinute + UnitSecond + UnitMillisecond + UnitMicrosecond ) -// unit is used to represent different time units (day, hour, minute, -// second, millisecond, microsecond) in numerical form. The zero value -// of 'unit' represents an invalid time unit. -type unit uint +// UnitMode defines the behavior for interpreting units in a duration +// string. It decides how many units can be accepted during the +// parsing. +type UnitMode int -// These constants represent different units of time used in the -// duration parsing process. They are ordered in decreasing magnitude -// from day to microsecond. The zero value of 'unit' type is reserved -// to represent an invalid unit. const ( - day unit = iota + 1 - hour - minute - second - millisecond - microsecond + // MultiUnitMode allows for multiple units to be specified + // together in the duration string, e.g., "1d2h3m". + MultiUnitMode UnitMode = iota + 1 + + // SingleUnitMode permits only a single unit type to be + // present in the duration string. Any subsequent unit types + // will result in an error. For instance, "1d" would be valid, + // but "1d2h" would not. + SingleUnitMode ) +// Unit is used to represent different time units (day, hour, minute, +// second, millisecond, microsecond) in numerical form. The zero value +// represents an invalid time unit. +type Unit uint + +// unitInfo defines a time unit's symbol and its corresponding +// duration. +type unitInfo struct { + Symbol string // String representation of the time unit, e.g., "h" for hour. + Duration time.Duration // Duration of the unit. +} + +// SyntaxError represents an error that occurs during parsing of a +// duration string. It provides details about the specific nature of +// the error and the position in the string where the error was +// detected. +type SyntaxError struct { + // Msg contains a description of the error. + Msg string + + // Position represents the location in the input string where + // the error was detected. The Position is 0-indexed. + Position int +} + +// OverflowError represents an error that occurs when a parsed value +// exceeds the allowable range, leading to an overflow condition. +type OverflowError struct { + // Position represents the location in the input string where + // the error was detected. The Position is 0-indexed. + Position int + + // Number is the substring of the input string that represents + // the numeric value causing the overflow. This provides a + // direct reference to the original representation of the + // Number in the input. + Number string +} + +// ParserConfig is an opaque type for client code. A valid +// configuration can be constructed with NewParserConfig. +type ParserConfig struct { + *parserConfig +} + +// parserConfig provides configuration options for the ParseDuration +// function. This allows for customisation of parsing behaviour based +// on user requirements. +type parserConfig struct { + // permissibleUnits defines the set of time units that are + // considered valid during the parsing process. If a unit is + // not present within this slice, it is treated as an error. + // This offers flexibility to restrict or allow specific time + // units based on use-case needs. + permissibleUnits []Unit + + // defaultUnit specifies the unit that should be assumed for + // numeric durations that don't have a unit. For instance, if + // defaultUnit is set to Hour, a solitary value of "5" without + // any associated unit would be treated as "5h". + defaultUnit Unit + + // unitMode determines the parsing behavior of the duration + // string. In MultiMode, multiple units can be specified + // together in the duration string (e.g., "1d2h3m"). In + // SingleMode, only one unit is allowed in the duration + // string, and any subsequent units result in an error. Valid + // values are MultiMode or SingleMode. + unitMode UnitMode +} + +// token represents a parsed segment of the input duration string. +// Each token captures the position of the parsed segment in the +// input, the unit of time (e.g., seconds, minutes), and the raw +// numeric value associated with that unit. type token struct { + // position indicates the starting position of this token + // within the input string. The position is 0-indexed. + position int + + // unit identifies the time unit (e.g., seconds, minutes) + // associated with the value parsed from the input string. + unit Unit + + // value is the numeric representation of the number segment + // parsed from the input, associated with the specified unit. value int64 - unit - duration time.Duration + + // number is the substring of the input string that represents + // the numeric value. + number string } +// Parser processes duration strings, converting them into structured +// tokens and facilitating the computation of their overall duration. type parser struct { - tokens []*token - current int // current offset in input - position int // parse error location in input + // config contains the configuration parameters that dictate + // the behavior and constraints of the parser. + config *parserConfig + + // tokens holds the list of parsed tokens extracted from the + // input string. Each token corresponds to a segment of the + // duration string. + tokens []*token + + // current represents the current offset within the input + // string during parsing. It's updated as the parser processes + // each character of the input. + current int + + // position denotes the location in the input string where a + // potential error is detected during parsing. The position is + // 0-indexed. + position int +} + +var ( + // ParseErrMsgInvalidNumber represents an error when a + // provided number in the duration string is invalid or cannot + // be interpreted. + ParseErrMsgInvalidNumber = "invalid number" + + // ParseErrMsgInvalidUnit represents an error when an + // unrecognized or unsupported unit is used in the duration + // string. + ParseErrMsgInvalidUnit = "invalid unit" + + // ParseErrMsgInvalidUnitOrder represents an error when units + // in the duration string are not in decreasing order of + // magnitude (e.g., specifying minutes before hours). + ParseErrMsgInvalidUnitOrder = "invalid unit order" + + // ParseErrMsgUnexpectedChars indicates that characters were + // encountered beyond the first valid duration when parsing in + // SingleUnitMode. This error suggests that the input string + // may contain multiple unit-value pairs or extraneous + // characters which are not permitted when using + // SingleUnitMode. + ParseErrMsgUnexpectedChars = "unexpected characters after valid duration" + + // ConfigErrorInvalidDefaultUnit is returned when an invalid + // default time unit is specified in the configuration. + ConfigErrorInvalidDefaultUnit = errors.New("invalid default unit") + + // ConfigErrorInvalidUnitMode is returned when an invalid unit + // mode (not one of MultiUnitMode or SingleUnitMode) is + // specified in the configuration. + ConfigErrorInvalidUnitMode = errors.New("invalid unit mode") + + // unitProperties maps Units to their details. + unitProperties = map[Unit]unitInfo{ + UnitDay: {Symbol: "d", Duration: 24 * time.Hour}, + UnitHour: {Symbol: "h", Duration: time.Hour}, + UnitMinute: {Symbol: "m", Duration: time.Minute}, + UnitSecond: {Symbol: "s", Duration: time.Second}, + UnitMillisecond: {Symbol: "ms", Duration: time.Millisecond}, + UnitMicrosecond: {Symbol: "us", Duration: time.Microsecond}, + } + + // symbolToUnit maps time unit symbols to their corresponding + // Units. + symbolToUnit = map[string]Unit{ + "d": UnitDay, + "h": UnitHour, + "m": UnitMinute, + "s": UnitSecond, + "ms": UnitMillisecond, + "us": UnitMicrosecond, + } + + // allUnits is a slice containing all the defined time units, + // from the largest (day) to the smallest (microsecond). It + // can be used for iterating through or checking valid units + // in a structured manner. + allUnits = []Unit{ + UnitDay, + UnitHour, + UnitMinute, + UnitSecond, + UnitMillisecond, + UnitMicrosecond, + } + + // defaultParserConfig is the default configuration used by + // ParseDuration. It provides a shared reference to avoid + // re-initialising the parser configuration for each call to + // ParseDuration. + defaultParserConfig *ParserConfig +) + +func init() { + // We can ignore the error here because we have this + // invocation covered by a unit test. + defaultParserConfig, _ = NewParserConfig(UnitMillisecond, SingleUnitMode) +} + +// Error implements the error interface for ParseError. It provides a +// formatted error message detailing the position (1-index based) and +// the nature of the parsing error. +func (e *SyntaxError) Error() string { + return fmt.Sprintf("syntax error at position %v: %v", e.Position+1, e.Msg) +} + +// Error implements the error interface for OverflowError. It provides +// a formatted error message indicating which value caused an +// overflow. +func (e *OverflowError) Error() string { + return fmt.Sprintf("overflow error at position %d: value %v causes duration overflow", e.Position+1, e.Number) } +// parse processes the input string to match numeric values with an +// optional unit. func (p *parser) parse(input string) error { p.tokens = make([]*token, 0, len(input)/2) - p.skipWhitespace(input) for p.current < len(input) { p.position = p.current @@ -96,153 +273,221 @@ func (p *parser) parse(input string) error { if err != nil { return err } - if err := p.validateToken(token); err != nil { - return err - } - p.tokens = append(p.tokens, token) - p.skipWhitespace(input) - } - return nil -} + // Verify that units in previous tokens occur in + // decreasing order of significance (for example, + // hours before minutes). + if len(p.tokens) > 0 && p.tokens[len(p.tokens)-1].unit >= token.unit { + return &SyntaxError{Msg: ParseErrMsgInvalidUnitOrder, Position: p.position} + } -func (p *parser) validateToken(token *token) error { - if len(p.tokens) > 0 { - prevUnit := p.tokens[len(p.tokens)-1].unit - if prevUnit >= token.unit { - return fmt.Errorf("invalid unit order") + // If in SingleUnitMode and there's still input + // remaining after the first token, return an error. + if p.config.unitMode == SingleUnitMode && p.current < len(input) { + return &SyntaxError{Msg: ParseErrMsgUnexpectedChars, Position: p.current} } - } - if token.duration < 0 { - return errParseDurationUnderflow + + p.tokens = append(p.tokens, token) + } return nil } func (p *parser) nextToken(input string) (*token, error) { - p.position = p.current - value, err := p.consumeNumber(input) - if err != nil { + token := token{position: p.current} + + if number, value, err := p.consumeNumber(input); err != nil { return nil, err + } else { + token.number = number + token.value = value + p.position = p.current } - p.position = p.current + // The unit string can be optional. If not found then the unit + // defaults to the defaultUnit defined in the parser config. unitStr := p.consumeUnit(input) if unitStr == "" { - unitStr = "ms" + unitProperty := unitProperties[p.config.defaultUnit] + unitStr = unitProperty.Symbol } - unit, found := strToUnit[unitStr] - if !found { - return nil, errors.New("invalid unit") + if unit, ok := symbolToUnit[unitStr]; !ok { + return nil, &SyntaxError{Msg: ParseErrMsgInvalidUnit, Position: p.position} + } else { + token.unit = unit } - return &token{value, unit, time.Duration(value) * unitToDuration[unit]}, nil + return &token, nil } -func (p *parser) consumeNumber(input string) (int64, error) { +func (p *parser) consumeNumber(input string) (string, int64, error) { start := p.current + for p.current < len(input) && unicode.IsDigit(rune(input[p.current])) { p.current++ } - if start == p.current { - // This yields a better error message compared to what - // strconv.ParseInt returns for the empty string. - return 0, errors.New("invalid number") + + number := input[start:p.current] + value, err := strconv.ParseInt(number, 10, 64) + if err != nil { + nerr, _ := err.(*strconv.NumError) + switch nerr.Err { + case strconv.ErrSyntax: + return number, 0, &SyntaxError{Msg: ParseErrMsgInvalidNumber, Position: p.position} + case strconv.ErrRange: + return number, 0, &OverflowError{Position: p.position, Number: number} + } } - return strconv.ParseInt(input[start:p.current], 10, 64) + + return number, value, nil } func (p *parser) consumeUnit(input string) string { - start := p.current - for p.current < len(input) && unicode.IsLetter(rune(input[p.current])) { - p.current++ + // Prioritize longer symbols by checking for those first. + for _, symbol := range []string{"ms", "us"} { + if strings.HasPrefix(input[p.current:], symbol) { + p.current += len(symbol) + return symbol + } } - return input[start:p.current] -} -func (p *parser) skipWhitespace(input string) { - for p.current < len(input) && unicode.IsSpace(rune(input[p.current])) { + // Check for single character symbols. + if p.current < len(input) && unicode.IsLetter(rune(input[p.current])) { p.current++ + return input[p.current-1 : p.current] } + + return "" } -// ParseDuration translates a string representing a time duration into -// a time.Duration type. The input string can comprise duration values -// with units of days ("d"), hours ("h"), minutes ("m"), seconds -// ("s"), milliseconds ("ms"), and microseconds ("us"). If no unit is -// provided, the default is milliseconds ("ms"). If the input string -// comprises multiple duration values, they are summed to calculate -// the total duration. For example, "1h30m" is interpreted as 1 hour + -// 30 minutes. +// NewParserConfig creates a new ParserConfig using the provided +// units, default unit, and mode. // -// Returns: +// units specifies the permissible units that the parser will +// recognize. defaultUnit is the unit that will be used if an input +// duration string does not specify any unit. mode determines the +// parsing behavior with regards to units. In MultiUnitMode, multiple +// units can be specified together in the duration string (e.g., +// "1d2h3m"). In SingleUnitMode, only one unit is allowed in the +// duration string, and subsequent units result in an error. // -// - A time.Duration value representing the total duration found in -// the string. +// The function validates the provided configuration and returns an +// error if the configuration is invalid. // -// - An integer value indicating the position in the input string -// where parsing failed. +// Usage: // -// - An error value representing any parsing error that occurred. +// config, err := NewParserConfig([]Unit{UnitHour, UnitMinute}, UnitMinute, SingleUnitMode) +// if err != nil { +// log.Fatalf("Invalid parser config: %v", err) +// } // -// Errors: +// duration, err := ParseDurationWithConfig("15m", config) +// if err != nil { +// log.Fatalf("Failed to parse duration: %v", err) +// } +func NewParserConfig(defaultUnit Unit, mode UnitMode) (*ParserConfig, error) { + config := parserConfig{ + permissibleUnits: allUnits, + defaultUnit: defaultUnit, + unitMode: mode, + } + + // Ensure that the default unit is valid. + defaultUnitIsValid := false + for _, u := range config.permissibleUnits { + if u == config.defaultUnit { + defaultUnitIsValid = true + break + } + } + if !defaultUnitIsValid { + return nil, ConfigErrorInvalidDefaultUnit + } + + // Ensure that the unit mode is valid. + if config.unitMode != MultiUnitMode && config.unitMode != SingleUnitMode { + return nil, ConfigErrorInvalidUnitMode + } + + return &ParserConfig{&config}, nil +} + +// ParseDurationWithConfig translates a string representing a time +// duration into a time.Duration type. The input string can contain +// values with units: "d" (days), "h" (hours), "m" (minutes), "s" +// (seconds), "ms" (milliseconds), and "us" (microseconds). If no unit +// is specified, it defaults to "ms" (milliseconds). For multiple +// duration values, they are summed; e.g., "1h30m" equals 1 hour + 30 +// minutes. +// +// Valid Examples: +// - "10s" +// - "1h 30m" +// - "500ms" +// - "100us" +// - "1d 5m 200" (200 defaults to milliseconds) // -// - It returns an "invalid number" error when a non-numeric or -// improperly formatted numeric value is found. +// Spaces between values and units are optional. An empty string +// returns a zero duration. // -// - It returns an "invalid unit" error when an unrecognised or -// invalid time unit is provided. +// Returns: +// - A time.Duration representing the parsed duration. +// - An error, if any parsing issues arise. // -// - It returns an "invalid unit order" error when the time units in -// the input string are not in descending order from day to -// microsecond or when the same unit is specified more than once. +// Errors: // -// - It returns an "overflow" error when the total duration value -// exceeds the maximum possible value that a time.Duration can -// represent. +// - Non-numeric or improperly formatted values. // -// - It returns an "underflow" error if any individual time value in -// the input cannot be represented by time.Duration. For example, -// a duration of "9223372036s 1000ms" would return an underflow -// error. +// - Unrecognised or invalid time units. // -// The function extracts duration values and their corresponding units -// from the input string and calculates the total duration. It -// tolerates missing units as long as the subsequent units are -// presented in descending order of "d", "h", "m", "s", "ms", and -// "us". A duration value provided without a unit is treated as -// milliseconds by default. +// - Units not in descending order from day to microsecond or +// repeated units. // -// Some examples of valid input strings are: "10s", "1h 30m", "500ms", -// "100us", "1d 5m 200"; in the last example 200 will default to -// milliseconds. Spaces are also optional. +// - Total duration exceeding time.Duration's representational +// limit. // -// If an empty string is given as input, the function returns zero for -// the duration and no error. -func ParseDuration(input string) (time.Duration, int, error) { - p := parser{} +// - Any individual value in the input string leading to an +// overflow. +func ParseDurationWithConfig(input string, config *ParserConfig) (time.Duration, error) { + p := parser{config: config.parserConfig} if err := p.parse(input); err != nil { - return 0, p.position, err + return 0, err } - checkedAddDurations := func(x, y time.Duration) (time.Duration, error) { - result := x + y - if x > 0 && y > 0 && result < 0 { - return 0, errParseDurationOverflow - } - return result, nil - } - - var err error var total time.Duration + for _, t := range p.tokens { + duration := time.Duration(t.value) * unitProperties[t.unit].Duration + + // Check for overflow on individual token. + if t.value > 0 && duration < 0 { + return 0, &OverflowError{Number: t.number, Position: t.position} + } - for i := range p.tokens { - if total, err = checkedAddDurations(total, p.tokens[i].duration); err != nil { - return 0, 0, err + // Check for overflow when adding to total. + result := total + duration + if total > 0 && duration > 0 && result < 0 { + return 0, &OverflowError{Number: t.number, Position: t.position} } + + total = result } - return total, 0, nil + return total, nil +} + +// ParseDuration translates a string representing a time duration into +// a time.Duration using the default configuration. It's a convenience +// function that wraps around ParseDurationWithConfig, allowing users +// to quickly parse durations without custom configurations. +func ParseDuration(input string) (time.Duration, error) { + return ParseDurationWithConfig(input, defaultParserConfig) +} + +// DurationExceedsMaxTimeout checks if the given duration exceeds the +// predefined maximum timeout in milliseconds, as defined by +// MaximumTimeoutInMilliseconds. +func DurationExceedsMaxTimeout(d time.Duration) bool { + return d > MaxTimeoutInMilliseconds } diff --git a/duration_parser_test.go b/duration_parser_test.go index 361e434..b456f3b 100644 --- a/duration_parser_test.go +++ b/duration_parser_test.go @@ -12,137 +12,278 @@ func TestParseDuration(t *testing.T) { description string input string duration time.Duration - error string + errMsg string }{{ - description: "test with empty string", + description: "empty string", input: "", duration: 0, }, { - description: "test with string that is just spaces", - input: " ", + description: "zero milliseconds", + input: "0", duration: 0, }, { - description: "test for zero", - input: "0", + description: "leading +", + input: "+0", + duration: 0, + errMsg: "syntax error at position 1: invalid number", + }, { + description: "negative number", + input: "-1", duration: 0, + errMsg: "syntax error at position 1: invalid number", }, { - description: "invalid number", - input: "a", - error: "invalid number", + description: "abc is an invalid number", + input: "abc", + errMsg: "syntax error at position 1: invalid number", }, { - description: "invalid number", + description: "/ is an invalid number", input: "/", - error: "invalid number", + errMsg: "syntax error at position 1: invalid number", }, { - description: "invalid number, because the 100 defaults to 100ms", + description: "decimal point is an invalid number; the 100 defaults to 100ms follwed by .", input: "100.d", - error: "invalid number", + errMsg: "syntax error at position 4: invalid number", }, { - description: "invalid unit", - input: "1d 30mgarbage", - error: "invalid unit", + description: "Zzz is an invalid number after the valid 1d30m", + input: "1d30mZzz", + errMsg: "syntax error at position 6: invalid number", }, { - description: "valid test with spaces", - input: "1d 3h 30m 45s 100ms 200us", - duration: 27*time.Hour + 30*time.Minute + 45*time.Second + 100*time.Millisecond + 200*time.Microsecond, + description: "Zzz is an invalid unit after the valid 1d30m", + input: "1d30m1Zzz", + errMsg: "syntax error at position 7: invalid unit", }, { - description: "valid test with no space", + description: "all units specified", input: "1d3h30m45s100ms200us", duration: 27*time.Hour + 30*time.Minute + 45*time.Second + 100*time.Millisecond + 200*time.Microsecond, }, { - description: "test with leading and trailing spaces", - input: " 1d 3h 30m 45s ", - duration: 27*time.Hour + 30*time.Minute + 45*time.Second, - }, { - description: "test with no unit (assume milliseconds)", + description: "default unit", input: "5000", duration: 5000 * time.Millisecond, }, { - description: "test with no unit (assume milliseconds), followed by another millisecond value", - input: "5000 100ms", - error: "invalid unit order", + description: "number with leading zeros", + input: "0101us", + duration: 101 * time.Microsecond, }, { - description: "test number with leading zeros", - input: "000000000000000000000001 01us", - duration: time.Millisecond + time.Microsecond, - }, { - description: "test for zero milliseconds", + description: "zero milliseconds", input: "0ms", duration: 0, }, { - description: "test all units as zero", - input: "0d 0h 0m 0s 0ms 0us", + description: "all units as zero", + input: "0d0h0m0s0ms0us", duration: 0, }, { - description: "test all units as zero with implicit milliseconds", - input: "0d 0h 0m 0s 0 0us", + description: "all units as zero with implicit milliseconds", + input: "0d0h0m0s00us", duration: 0, }, { - description: "test with all zeros, and trailing 0 with no unit but ms has already been specified", - input: "0d 0h 0m 0s 0ms 0", - error: "invalid unit order", - }, { - description: "test 1 millisecond", - input: "0d 0h 0m 0s 1", + description: "1 millisecond", + input: "0d0h0m0s1", duration: time.Millisecond, }, { - description: "test duplicate units", - input: "0ms 0ms", - error: "invalid unit order", + description: "duplicate units", + input: "0ms0ms", + errMsg: "syntax error at position 5: invalid unit order", }, { - description: "test out of order units, hours cannot follow minutes", - input: "1d 5m 1h", - error: "invalid unit order", + description: "out of order units, hours cannot follow minutes", + input: "1d5m1h", + errMsg: "syntax error at position 6: invalid unit order", }, { - description: "test skipped units", - input: "1d 100us", + description: "skipped units", + input: "1d100us", duration: 24*time.Hour + 100*time.Microsecond, }, { - description: "test maximum number of seconds", + description: "maximum number of seconds", input: "9223372036s", duration: 9223372036 * time.Second, }, { - description: "test overflow", - input: "9223372036s 1000ms", - error: "overflow", + description: "overflow value with multiple units", + input: "9223372036s1000ms", + errMsg: "overflow error at position 12: value 1000 causes duration overflow", }, { - description: "test underflow", + description: "overflow with single unit", input: "9223372037s", - error: "underflow", + errMsg: "overflow error at position 1: value 9223372037 causes duration overflow", + }, { + description: "improbable duration", + input: "92233720371234567890s", + errMsg: "overflow error at position 1: value 92233720371234567890 causes duration overflow", }} + parserCfg, err := haproxytime.NewParserConfig(haproxytime.UnitMillisecond, haproxytime.MultiUnitMode) + if err != nil { + t.Fatalf("failed to create parser configuration: %v", err) + } + for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { - got, _, err := haproxytime.ParseDuration(tc.input) - if err != nil && err.Error() != tc.error { - t.Errorf("%q: wanted error %q, got %q", tc.input, tc.error, err) + actual, err := haproxytime.ParseDurationWithConfig(tc.input, parserCfg) + if err != nil { + if tc.errMsg == "" { + t.Errorf("unexpected error: %v", err) + return + } + if err.Error() != tc.errMsg { + t.Errorf("expected error message '%s', but got '%s'", tc.errMsg, err.Error()) + return + } + } else if tc.errMsg != "" { + t.Errorf("expected error message '%s', but got none", tc.errMsg) return } - if got != tc.duration { - t.Errorf("%q: wanted value %q, got %q", tc.input, tc.duration, got) + + if actual != tc.duration { + t.Errorf("expected duration %v, but got %v", tc.duration, actual) + } + }) + } +} + +func TestDurationExceedsHAProxyMax(t *testing.T) { + tests := []struct { + name string + duration time.Duration + exceedsMax bool + }{{ + name: "exactly max timeout", + duration: time.Duration(haproxytime.MaxTimeoutInMilliseconds), + exceedsMax: false, + }, { + name: "just above max timeout", + duration: time.Duration(haproxytime.MaxTimeoutInMilliseconds) + time.Millisecond, + exceedsMax: true, + }, { + name: "just below max timeout", + duration: time.Duration(haproxytime.MaxTimeoutInMilliseconds) - time.Millisecond, + exceedsMax: false, + }, { + name: "zero duration", + duration: 0, + exceedsMax: false, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := haproxytime.DurationExceedsMaxTimeout(test.duration) + if result != test.exceedsMax { + t.Errorf("Expected %v, got %v for duration %v", test.exceedsMax, result, test.duration) + } + }) + } +} + +// TestNewParserConfig exercises various possible configurations and +// misconfigurations: +// +// A valid configuration in multi-unit mode. +// +// An error case where no permissible units are provided. +// +// An error case where the default unit is not in the list of +// permissible units. +// +// An error case with an invalid unit mode. +func TestNewParserConfig(t *testing.T) { + tests := []struct { + name string + defaultUnit haproxytime.Unit + mode haproxytime.UnitMode + expectedErr error + }{{ + name: "default unit < UnitMode", + defaultUnit: haproxytime.UnitDay - 1, + mode: haproxytime.MultiUnitMode, + expectedErr: haproxytime.ConfigErrorInvalidDefaultUnit, + }, { + name: "default unit > UnitMicrosecond", + defaultUnit: haproxytime.UnitMicrosecond + 1, + mode: haproxytime.MultiUnitMode, + expectedErr: haproxytime.ConfigErrorInvalidDefaultUnit, + }, { + name: "invalid unit mode", + defaultUnit: haproxytime.UnitSecond, + mode: 0, + expectedErr: haproxytime.ConfigErrorInvalidUnitMode, + }, { + name: "valid configuration", + defaultUnit: haproxytime.UnitSecond, + mode: haproxytime.MultiUnitMode, + expectedErr: nil, + }, { + name: "default configuration is valid", + defaultUnit: haproxytime.UnitMicrosecond, + mode: haproxytime.SingleUnitMode, + expectedErr: nil, + }} + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := haproxytime.NewParserConfig(tc.defaultUnit, tc.mode) + if err != nil && tc.expectedErr == nil { + t.Fatalf("expected no error, but got: %v", err) + } + if err == nil && tc.expectedErr != nil { + t.Fatalf("expected error %v, but got none", tc.expectedErr) + } + if err != nil && tc.expectedErr != nil && err != tc.expectedErr { + t.Fatalf("expected error %v, but got: %v", tc.expectedErr, err) + } + }) + } +} + +func TestParseDurationInSingleUnitMode(t *testing.T) { + tests := []struct { + name string + input string + config haproxytime.ParserConfig + errMsg string + }{{ + name: "invalid unit", + input: "5x6s", + errMsg: "syntax error at position 2: invalid unit", + }, { + name: "trailing characters after valid duration in single-unit mode", + input: "100d100s", + errMsg: "syntax error at position 5: unexpected characters after valid duration", + }} + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := haproxytime.ParseDuration(tc.input) + if err == nil { + t.Fatalf("input=%q; expected error, got none", tc.input) + } + if err.Error() != tc.errMsg { + t.Fatalf("input=%q; expected error: %q, got: %q", tc.input, tc.errMsg, err.Error()) } }) } } func FuzzParseDuration(f *testing.F) { - f.Add("") - f.Add("0") - f.Add("0d") - f.Add("0ms") - f.Add("1000garbage") - f.Add("100us") - f.Add("10s") - f.Add("1d 3h") - f.Add("1d") - f.Add("1d3h30m45s") - f.Add("1h30m") - f.Add("5000") - f.Add("500ms") - f.Add("9223372036s") - - // Values extracted from the unit tests - testCases := []string{ + seedCases := []string{ + "", + "0", + "0d", + "0ms", + "1000garbage", + "100us", + "10s", + "1d 3h", + "1d", + "1d3h30m45s", + "1h30m", + "5000", + "500ms", + "9223372036s", + } + + for _, s := range seedCases { + f.Add(s) + } + + // Values extracted from the unit tests. + unitTestCases := []string{ "", "0", "a", @@ -169,14 +310,14 @@ func FuzzParseDuration(f *testing.F) { "9223372037s", } - for _, tc := range testCases { + for _, tc := range unitTestCases { f.Add(tc) } f.Fuzz(func(t *testing.T, input string) { - _, _, err := haproxytime.ParseDuration(input) + _, err := haproxytime.ParseDuration(input) if err != nil { - t.Skip() + t.Skip() // Skip inputs that produce errors, focusing on potential panics. } }) } diff --git a/go.mod b/go.mod index 0847208..52b3a2c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/frobware/haproxytime -go 1.18 +go 1.20