From d3129c9f409630d0cab3297f60eea31f8040c9a1 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. --- .../haproxy-timeout-checker.go | 34 - cmd/haproxytimeout/main.go | 187 ++++++ duration_parser.go | 633 +++++++++++++----- duration_parser_test.go | 536 +++++++++++---- go.mod | 2 +- 5 files changed, 1071 insertions(+), 321 deletions(-) delete mode 100644 cmd/haproxy-timeout-checker/haproxy-timeout-checker.go create mode 100644 cmd/haproxytimeout/main.go diff --git a/cmd/haproxy-timeout-checker/haproxy-timeout-checker.go b/cmd/haproxy-timeout-checker/haproxy-timeout-checker.go deleted file mode 100644 index 93fec65..0000000 --- a/cmd/haproxy-timeout-checker/haproxy-timeout-checker.go +++ /dev/null @@ -1,34 +0,0 @@ -package main - -import ( - "fmt" - "os" - "time" - - "github.com/frobware/haproxytime" -) - -const HAProxyMaxTimeoutValue = 2147483647 * time.Millisecond - -func main() { - if len(os.Args) < 2 { - fmt.Println(`usage: `) - os.Exit(1) - } - - duration, position, err := haproxytime.ParseDuration(os.Args[1]) - 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) - } - - 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) - } - - fmt.Println(duration.Milliseconds()) -} diff --git a/cmd/haproxytimeout/main.go b/cmd/haproxytimeout/main.go new file mode 100644 index 0000000..7bc6ee5 --- /dev/null +++ b/cmd/haproxytimeout/main.go @@ -0,0 +1,187 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "time" + + "github.com/frobware/haproxytime" +) + +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 formatDuration(d time.Duration) string { + const ( + Day = time.Hour * 24 + Month = Day * 30 + Year = Day * 365 + Microsecond = time.Microsecond + ) + + years := d / Year + d -= years * Year + + months := d / Month + d -= months * Month + + days := d / Day + d -= days * Day + + hours := d / time.Hour + d -= hours * time.Hour + + minutes := d / time.Minute + d -= minutes * time.Minute + + seconds := d / time.Second + d -= seconds * time.Second + + milliseconds := d / time.Millisecond + d -= milliseconds * time.Millisecond + + microseconds := d / Microsecond + + var result string + if years > 0 { + result += fmt.Sprintf("%dy", years) + } + if months > 0 { + result += fmt.Sprintf("%dmo", months) + } + if days > 0 { + result += fmt.Sprintf("%dd", days) + } + if hours > 0 { + result += fmt.Sprintf("%dh", hours) + } + if minutes > 0 { + result += fmt.Sprintf("%dm", minutes) + } + if seconds > 0 { + result += fmt.Sprintf("%ds", seconds) + } + if milliseconds > 0 { + result += fmt.Sprintf("%dms", milliseconds) + } + if microseconds > 0 { + result += fmt.Sprintf("%dus", microseconds) + } + + return result +} + +func main() { + max := flag.Bool("m", false, "Print the maximum duration HAProxy can tolerate and exit.") + jsonOutput := flag.Bool("j", false, "Print results in JSON format.") + flag.Parse() + + args := flag.Args() + + if len(args) == 0 && !*max { + fmt.Println(`usage: haproxytimeout [-m] [-j] + -m: Print the maximum duration HAProxy can tolerate and exit. + -j: Print results in JSON format. (e.g., haproxytimeout -j | jq .milliseconds + +Input Assumptions: + If the input is a plain number (or a decimal without a unit), it's + assumed to be in milliseconds. For example: 'haproxytimeout 1500' + is assumed to be '1500ms'. + +Output Format: + + The output maps the human-readable format to the milliseconds + format. For example: 'haproxytimeout 2h30m' will output '2h30m -> + 9000000ms'. The right-hand side is always in milliseconds, making it + suitable for direct use in a haproxy.cfg file. + +Available units: + d days + h: hours + m: minutes + s: seconds + ms: milliseconds + us: microseconds + +Example usage: + haproxytimeout 2h30m -> Convert 2 hours 30 minutes to milliseconds. + haproxytimeout 1500ms -> Convert 1500 milliseconds to a human-readable format. + haproxytimeout -j 1h17m -> Get JSON output for 1h 17minutes + haproxytimeout -m -> Show the maximum duration HAProxy can tolerate.`) + os.Exit(1) + } + + type outputResult struct { + HumanReadable string `json:"human_readable"` + Milliseconds int64 `json:"milliseconds"` + } + + // Output function based on jsonOutput flag. + output := func(human string, millis int64) { + if human == "" { + human = formatDuration(time.Duration(millis) * time.Millisecond) + } + + if *jsonOutput { + res := outputResult{ + HumanReadable: human, + Milliseconds: millis, + } + jsonRes, err := json.Marshal(res) + if err != nil { + fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err) + os.Exit(6) + } + fmt.Println(string(jsonRes)) + } else { + fmt.Printf("%s -> %dms\n", human, millis) + } + } + + // Handle max duration request. + if *max { + maxDuration := haproxytime.MaxTimeout.Milliseconds() + // Always format max duration as human-readable. + output(formatDuration(haproxytime.MaxTimeout), maxDuration) + return + } + + parserCfg, err := haproxytime.NewParserConfig(haproxytime.UnitMillisecond, haproxytime.ModeMultiUnit) + if err != nil { + fmt.Fprintf(os.Stderr, "error: failed to create parser configuration: %v\n", err) + os.Exit(2) + } + + duration, err := haproxytime.ParseDurationWithConfig(args[0], parserCfg) + if err != nil { + var se *haproxytime.SyntaxError + var oe *haproxytime.OverflowError + switch { + case errors.As(err, &se): + printErrorWithPosition(args[0], err, se.Position) + os.Exit(3) + case errors.As(err, &oe): + printErrorWithPosition(args[0], err, oe.Position) + os.Exit(4) + default: + panic(err) + } + } + + if haproxytime.DurationExceedsMaxTimeout(duration) { + maxHumanFormat := formatDuration(haproxytime.MaxTimeout) + humanFormat := formatDuration(time.Duration(duration.Milliseconds()) * time.Millisecond) + fmt.Fprintf(os.Stderr, "%s exceeds HAProxy's maximum duration %s (%vms)\n", + humanFormat, maxHumanFormat, haproxytime.MaxTimeout.Milliseconds()) + os.Exit(5) + } + + output(formatDuration(duration), duration.Milliseconds()) +} diff --git a/duration_parser.go b/duration_parser.go index c8c4375..ecb9263 100644 --- a/duration_parser.go +++ b/duration_parser.go @@ -1,94 +1,273 @@ -// 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 like "h", "m", and "s", it +// also supports days (represented as "d"), which are unavailable in +// the built-in time.ParseDuration function. All durations parsed by +// this package are ensured to be non-negative and won't overflow the +// time.Duration value type. Furthermore, the parsed value cannot +// exceed HAProxy's MaxTimeout value, ensuring compatibility with +// HAProxy's configuration constraints. 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, - } +// MaxTimeout 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 MaxTimeout = 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 +// ParsingMode defines the behavior for interpreting units in a +// duration string. It decides how many units can be accepted during +// the parsing. +type ParsingMode 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 + // ModeMultiUnit allows for multiple units to be specified + // together in the duration string, e.g., "1d2h3m". + ModeMultiUnit ParsingMode = iota + 1 + + // ModeSingleUnit 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. + ModeSingleUnit ) +// 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 ParsingMode +} + +// 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 ( + // SyntaxErrMsgInvalidNumber represents an error when a + // provided number in the duration string is invalid or cannot + // be interpreted. + SyntaxErrMsgInvalidNumber = "invalid number" + + // SyntaxErrMsgInvalidUnit represents an error when an + // unrecognised or unsupported unit is used in the duration + // string. + SyntaxErrMsgInvalidUnit = "invalid unit" + + // SyntaxErrMsgInvalidUnitOrder represents an error when units + // in the duration string are not in decreasing order of + // magnitude (e.g., specifying minutes before hours). + SyntaxErrMsgInvalidUnitOrder = "invalid unit order" + + // SyntaxErrMsgUnexpectedChars 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. + SyntaxErrMsgUnexpectedChars = "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, ModeSingleUnit) +} + +// 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 +275,287 @@ 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: SyntaxErrMsgInvalidUnitOrder, 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 == ModeSingleUnit && p.current < len(input) { + return &SyntaxError{Msg: SyntaxErrMsgUnexpectedChars, Position: p.current} } - } - if token.duration < 0 { - return errParseDurationUnderflow + + p.tokens = append(p.tokens, token) + } return nil } +// nextToken consumes and returns the next token from the provided +// input string. It first tries to consume a number, and if +// successful, sets its value and position. It then tries to consume a +// unit. If not found, the unit defaults to the defaultUnit defined in +// the parser config. If the unit is invalid, a SyntaxError is +// returned. +// +// Parameters: +// - input: The input string to parse and extract a token from. +// +// Returns: +// +// - A pointer to the token extracted, if successful. +// +// - An error if the token extraction fails due to invalid format or +// unrecognised units. 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: SyntaxErrMsgInvalidUnit, 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 { + switch { + case errors.Is(err, strconv.ErrSyntax): + return number, 0, &SyntaxError{Msg: SyntaxErrMsgInvalidNumber, Position: p.position} + case errors.Is(err, strconv.ErrRange): + return number, 0, &OverflowError{Position: p.position, Number: number} + } } - return strconv.ParseInt(input[start:p.current], 10, 64) + + return number, value, nil } +// consumeNumber parses and extracts the next continuous sequence of +// digits from the provided input string, starting from the current +// parser position. Once the number is parsed, it converts the number +// string to an int64 value. +// +// Parameters: +// - input: The input string from which a number needs to be parsed. +// +// Returns: +// +// - The string representation of the number parsed. +// +// - The int64 representation of the parsed number. +// +// - An error if the parsing fails due to invalid number format, +// range overflow, or any other parsing-related issue. This function +// can return two specific error types: SyntaxError, for invalid +// number syntax, and OverflowError, for number values that fall +// outside the int64 range. func (p *parser) consumeUnit(input string) string { - start := p.current - for p.current < len(input) && unicode.IsLetter(rune(input[p.current])) { - p.current++ + // Prioritise 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 "" +} + +// 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 > MaxTimeout } -// 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. +// Is checks whether the provided target error matches the SyntaxError +// type. This method facilitates the use of the errors.Is function for +// matching against SyntaxError. // -// Returns: +// Example: // -// - A time.Duration value representing the total duration found in -// the string. +// if errors.Is(err, &haproxytime.SyntaxError{}) { +// // handle SyntaxError +// } +func (e *SyntaxError) Is(target error) bool { + var syntaxError *SyntaxError + return errors.As(target, &syntaxError) +} + +// Is checks whether the provided target error matches the +// OverflowError type. This method facilitates the use of the +// errors.Is function for matching against OverflowError. // -// - An integer value indicating the position in the input string -// where parsing failed. +// Example: // -// - An error value representing any parsing error that occurred. +// if errors.Is(err, &haproxytime.OverflowError{}) { +// // handle OverflowError +// } +func (e *OverflowError) Is(target error) bool { + var overflowError *OverflowError + return errors.As(target, &overflowError) +} + +// NewParserConfig creates a new ParserConfig using the provided +// units, default unit, and mode. // -// Errors: +// units specifies the permissible units that the parser will +// recognise. defaultUnit is the unit that will be used if an input +// duration string does not specify any unit. mode determines the +// parsing behavior in regard 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. +// +// The function validates the provided configuration and returns an +// error if the configuration is invalid. +// +// Usage: +// +// config, err := NewParserConfig([]Unit{UnitHour, UnitMinute}, UnitMinute, SingleUnitMode) +// if err != nil { +// log.Fatalf("Invalid parser config: %v", err) +// } +// +// duration, err := ParseDurationWithConfig("15m", config) +// if err != nil { +// log.Fatalf("Failed to parse duration: %v", err) +// } +func NewParserConfig(defaultUnit Unit, mode ParsingMode) (*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 != ModeMultiUnit && config.unitMode != ModeSingleUnit { + return nil, ConfigErrorInvalidUnitMode + } + + return &ParserConfig{&config}, nil +} + +// ParseDurationWithConfig translates an input string representing a +// time duration into a time.Duration type. The string may include +// values with units like: +// +// "d" for days, "h" for hours, "m" for minutes, "s" for seconds, +// "ms" for milliseconds, and "us" for microseconds. // -// - It returns an "invalid number" error when a non-numeric or -// improperly formatted numeric value is found. +// If no unit is specified, the default is milliseconds. // -// - It returns an "invalid unit" error when an unrecognised or -// invalid time unit is provided. +// Examples: +// - "10s" -> 10 seconds +// - "1h30m" -> 1 hour + 30 minutes +// - "500ms" -> 500 milliseconds +// - "100us" -> 100 microseconds +// - "1d5m200" -> 1 day + 5 minutes + 200 milliseconds // -// - 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. +// An empty input results in a zero duration. // -// - It returns an "overflow" error when the total duration value -// exceeds the maximum possible value that a time.Duration can -// represent. +// Errors: // -// - 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. +// The function can return two types of errors: // -// 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. +// - SyntaxError: When the input has non-numeric values, +// unrecognised units, improperly formatted values, or units that +// are not in descending order from day to microsecond. // -// 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. +// - OverflowError: If the total duration exceeds HAProxy's maximum +// limit or any individual value in the input leads to an overflow. // -// 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{} +// Returns a time.Duration of the parsed input and an error, if any. +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 - } - - 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 + return 0, err } - 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. Also check + // for negative durations which might be underflows. + if duration > MaxTimeout || 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 total overflow (a + b > MaxTimeout). + if total > MaxTimeout-duration { + return 0, &OverflowError{Number: t.number, Position: t.position} } + + total += duration } - return total, 0, nil + return total, nil +} + +// ParseDuration converts an input string representing a time duration +// into a time.Duration using the default configuration. This function +// offers a straightforward approach for parsing durations without the +// need for custom configurations. +// +// Internally, it relies on ParseDurationWithConfig, employing the +// default configuration settings: +// +// - Mode: SingleUnitMode +// - Default Unit: UnitMicrosecond +// +// Usage: +// +// duration, err := ParseDuration("1d") +func ParseDuration(input string) (time.Duration, error) { + return ParseDurationWithConfig(input, defaultParserConfig) } diff --git a/duration_parser_test.go b/duration_parser_test.go index 361e434..97edea7 100644 --- a/duration_parser_test.go +++ b/duration_parser_test.go @@ -1,182 +1,466 @@ package haproxytime_test import ( + "errors" + "fmt" + "math" "testing" "time" "github.com/frobware/haproxytime" ) -func TestParseDuration(t *testing.T) { +// 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.ParsingMode + expectedErr error + }{{ + name: "default unit < UnitMode", + defaultUnit: haproxytime.UnitDay - 1, + mode: haproxytime.ModeMultiUnit, + expectedErr: haproxytime.ConfigErrorInvalidDefaultUnit, + }, { + name: "default unit > UnitMicrosecond", + defaultUnit: haproxytime.UnitMicrosecond + 1, + mode: haproxytime.ModeMultiUnit, + expectedErr: haproxytime.ConfigErrorInvalidDefaultUnit, + }, { + name: "invalid unit mode", + defaultUnit: haproxytime.UnitSecond, + mode: 0, + expectedErr: haproxytime.ConfigErrorInvalidUnitMode, + }, { + name: "valid configuration", + defaultUnit: haproxytime.UnitSecond, + mode: haproxytime.ModeMultiUnit, + expectedErr: nil, + }, { + name: "default configuration is valid", + defaultUnit: haproxytime.UnitMicrosecond, + mode: haproxytime.ModeSingleUnit, + 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 && !errors.Is(err, tc.expectedErr) { + t.Fatalf("expected error %v, but got: %v", tc.expectedErr, err) + } + }) + } +} + +// TestDurationExceedsHAProxyMax verifies the behavior of the +// DurationExceedsMaxTimeout function, which checks whether a given +// time.Duration exceeds HAProxy's maximum allowable timeout, and +// specifically uses the symbol haproxytime.MaxTimeout. +func TestDurationExceedsHAProxyMax(t *testing.T) { + testCases := []struct { + name string + duration time.Duration + exceedsMax bool + }{{ + name: "exactly HAProxy max timeout", + duration: haproxytime.MaxTimeout, + exceedsMax: false, + }, { + name: "just above HAProxy max timeout", + duration: haproxytime.MaxTimeout + time.Millisecond, + exceedsMax: true, + }, { + name: "just below HAProxy max timeout", + duration: haproxytime.MaxTimeout - time.Millisecond, + exceedsMax: false, + }, { + name: "zero duration", + duration: 0, + exceedsMax: false, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := haproxytime.DurationExceedsMaxTimeout(tc.duration) + if result != tc.exceedsMax { + t.Errorf("Expected %v, got %v for duration %v", tc.exceedsMax, result, tc.duration) + } + }) + } +} + +// TestParseDurationInSingleUnitMode verifies the behavior of the +// ParseDuration function when parsing durations in Single Unit Mode. +// In this mode, a duration string is expected to have only one type +// of time unit (like "5s" or "100ms"). This test ensures that the +// function correctly handles invalid units, trailing characters after +// valid durations, and other potential syntactical issues. +func TestParseDurationInSingleUnitMode(t *testing.T) { + testCases := []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 testCases { + 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()) + } + }) + } +} + +// TestSyntaxError_Error ensures that the error message produced by a +// SyntaxError provides accurate and clear information about the +// syntax issue encountered. The test crafts a duration string known +// to cause a syntax error and then verifies that the SyntaxError +// generated details the correct position and nature of the error. +func TestSyntaxError_Error(t *testing.T) { + parserCfg, _ := haproxytime.NewParserConfig(haproxytime.UnitMillisecond, haproxytime.ModeMultiUnit) + + input := "1h1x" + _, err := haproxytime.ParseDurationWithConfig(input, parserCfg) + var se *haproxytime.SyntaxError + ok := errors.As(err, &se) + if !ok { + t.Fatalf("expected SyntaxError, got %T", err) + } + + expected := "syntax error at position 4: invalid unit" + if se.Error() != expected { + t.Errorf("expected %q, but got %q", expected, se.Error()) + } +} + +// TestOverflowError_Error validates that the error message produced +// by an OverflowError accurately represents the cause of the +// overflow. The test crafts a duration string known to cause an +// overflow, then ensures that the OverflowError generated reports the +// correct position and value causing the overflow. +func TestOverflowError_Error(t *testing.T) { + input := "10000000000000000000" + unit := "d" + + _, err := haproxytime.ParseDuration(input + unit) + var oe *haproxytime.OverflowError + ok := errors.As(err, &oe) + if !ok { + t.Fatalf("expected OverflowError, got %T", err) + } + + expected := "overflow error at position 1: value " + input + " causes duration overflow" + if oe.Error() != expected { + t.Errorf("expected %q, but got %q", expected, oe.Error()) + } +} + +// TestParseDurationOverflowErrors verifies the proper handling of +// overflow errors when parsing duration strings. It ensures that +// values within the acceptable range do not produce errors, while +// values exceeding the limits are correctly identified and reported +// with detailed information, including the problematic number and its +// position within the input. +func TestParseDurationOverflowErrors(t *testing.T) { + testCases := []struct { + name string + input string + expectedErr error + duration time.Duration + }{{ + name: "maximum value without overflow (just under the limit)", + input: "2147483647ms", + expectedErr: nil, + duration: haproxytime.MaxTimeout, + }, { + name: "just over the limit", + input: "2147483648ms", + expectedErr: &haproxytime.OverflowError{Number: "2147483648", Position: 0}, + duration: 0, + }, { + name: "maximum value without overflow (using different time units)", + input: "2147483s647ms", + expectedErr: nil, + duration: 2147483*time.Second + 647*time.Millisecond, + }, { + name: "maximum value without overflow (using different time units)", + input: "35791m23s647ms", + expectedErr: nil, + duration: 35791*time.Minute + 23*time.Second + 647*time.Millisecond, + }, { + name: "over the limit with combined units", + input: "2147483s648ms", + expectedErr: &haproxytime.OverflowError{Number: "648", Position: 8}, + duration: 0, + }, { + name: "over the limit (using different time units)", + input: "35791m23s648ms", + expectedErr: &haproxytime.OverflowError{Number: "648", Position: 9}, + duration: 0, + }, { + name: "way below the limit", + input: "1000ms", + expectedErr: nil, + duration: 1000 * time.Millisecond, + }, { + name: "way below the limit", + input: "1s", + expectedErr: nil, + duration: 1 * time.Second, + }, { + name: "way over the limit", + input: "4294967295ms", + expectedErr: &haproxytime.OverflowError{Number: "4294967295", Position: 0}, + duration: 0, + }, { + name: "way, way over the limit", + input: "9223372036855ms", + duration: 0, + expectedErr: &haproxytime.OverflowError{Number: "9223372036855", Position: 0}, + }, { + name: "maximum value +1 (using different units)", + input: "24d20h31m23s648ms0us", + expectedErr: &haproxytime.OverflowError{Number: "648", Position: 12}, + duration: 0, + }, { + name: "maximum value +1 (using different units)", + input: "24d20h31m23s647ms0us", + duration: haproxytime.MaxTimeout, + }, { + name: "MaxInt32 milliseconds", + input: fmt.Sprintf("%dms", math.MaxInt32), + expectedErr: nil, + duration: time.Duration(math.MaxInt32) * time.Millisecond, + }, { + name: "MaxInt32+1 milliseconds", + input: fmt.Sprintf("%dms", int64(math.MaxInt32)+1), + expectedErr: &haproxytime.OverflowError{Number: fmt.Sprintf("%d", int64(math.MaxInt32)+1), Position: 0}, + duration: 0, + }, { + name: "MaxInt64 milliseconds", + input: fmt.Sprintf("%dms", math.MaxInt64), + expectedErr: &haproxytime.OverflowError{Number: fmt.Sprintf("%d", int64(math.MaxInt64)), Position: 0}, + duration: 0, + }} + + parserCfg, err := haproxytime.NewParserConfig(haproxytime.UnitMillisecond, haproxytime.ModeMultiUnit) + if err != nil { + t.Fatalf("failed to create parser configuration: %v", err) + } + + for _, tc := range testCases { + got, err := haproxytime.ParseDurationWithConfig(tc.input, parserCfg) + + if tc.expectedErr != nil { + var oe *haproxytime.OverflowError + if !(errors.As(err, &oe) && errors.Is(err, &haproxytime.OverflowError{})) { + t.Errorf("Is check failed for OverflowError, expected %T, but got %T", &haproxytime.OverflowError{}, err) + } else { + var expected *haproxytime.OverflowError + if !errors.As(tc.expectedErr, &expected) { + t.Error("failed to convert expected error to OverflowError") + return + } + if oe.Number != expected.Number || oe.Position != expected.Position { + t.Errorf("expected OverflowError fields %v, but got %v", *expected, *oe) + } + } + } else { + if err != nil { + t.Errorf("Didn't expect error for input %q but got %v", tc.input, err) + continue + } + if got != tc.duration { + t.Errorf("For input %q, expected %v but got %v", tc.input, tc.duration, got) + } + } + } +} + +// TestParseDurationSyntaxErrors verifies that duration strings are +// parsed correctly according to their syntax. It checks various valid +// and invalid inputs to ensure the parser handles syntax errors +// appropriately, identifying and reporting any inconsistencies or +// unsupported formats with a detailed error message and the position +// of the problematic segment. +func TestParseDurationSyntaxErrors(t *testing.T) { testCases := []struct { - description string + name string input string + expectedErr error duration time.Duration - error string }{{ - description: "test with empty string", + name: "empty string", input: "", + expectedErr: nil, duration: 0, }, { - description: "test with string that is just spaces", - input: " ", + name: "leading space is not a number", + input: " ", + expectedErr: &haproxytime.SyntaxError{Msg: "invalid number", Position: 0}, duration: 0, }, { - description: "test for zero", + name: "zero milliseconds", input: "0", + expectedErr: nil, duration: 0, }, { - description: "invalid number", - input: "a", - error: "invalid number", + name: "leading +", + input: "+0", + expectedErr: &haproxytime.SyntaxError{Msg: "invalid number", Position: 0}, + duration: 0, }, { - description: "invalid number", + name: "negative number", + input: "-1", + expectedErr: &haproxytime.SyntaxError{Msg: "invalid number", Position: 0}, + duration: 0, + }, { + name: "abc is an invalid number", + input: "abc", + expectedErr: &haproxytime.SyntaxError{Msg: "invalid number", Position: 0}, + duration: 0, + }, { + name: "/ is an invalid number", input: "/", - error: "invalid number", + expectedErr: &haproxytime.SyntaxError{Msg: "invalid number", Position: 0}, + duration: 0, }, { - description: "invalid number, because the 100 defaults to 100ms", + name: "decimal point is an invalid number; the 100 defaults to 100ms followed by .", input: "100.d", - error: "invalid number", + expectedErr: &haproxytime.SyntaxError{Msg: "invalid number", Position: 3}, + duration: 0, }, { - description: "invalid unit", - input: "1d 30mgarbage", - error: "invalid unit", + name: "Zzz is an invalid number after the valid 1d30m", + input: "1d30mZzz", + expectedErr: &haproxytime.SyntaxError{Msg: "invalid number", Position: 5}, + duration: 0, }, { - 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, + name: "Zzz is an invalid unit after the valid 1d30m", + input: "1d30m1Zzz", + expectedErr: &haproxytime.SyntaxError{Msg: "invalid unit", Position: 6}, + duration: 0, }, { - description: "valid test with no space", + name: "all units specified", input: "1d3h30m45s100ms200us", + expectedErr: nil, 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)", + name: "default unit", input: "5000", + expectedErr: nil, duration: 5000 * time.Millisecond, }, { - description: "test with no unit (assume milliseconds), followed by another millisecond value", - input: "5000 100ms", - error: "invalid unit order", + name: "number with leading zeros", + input: "0101us", + expectedErr: nil, + duration: 101 * time.Microsecond, }, { - description: "test number with leading zeros", - input: "000000000000000000000001 01us", - duration: time.Millisecond + time.Microsecond, - }, { - description: "test for zero milliseconds", + name: "zero milliseconds", input: "0ms", + expectedErr: nil, duration: 0, }, { - description: "test all units as zero", - input: "0d 0h 0m 0s 0ms 0us", + name: "all units as zero", + input: "0d0h0m0s0ms0us", + expectedErr: nil, duration: 0, }, { - description: "test all units as zero with implicit milliseconds", - input: "0d 0h 0m 0s 0 0us", + name: "all units as zero with implicit milliseconds", + input: "0d0h0m0s00us", + expectedErr: nil, 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", + name: "1 millisecond", + input: "0d0h0m0s1", + expectedErr: nil, duration: time.Millisecond, }, { - description: "test duplicate units", - input: "0ms 0ms", - error: "invalid unit order", + name: "duplicate units", + input: "0ms0ms", + expectedErr: &haproxytime.SyntaxError{Msg: "invalid unit order", Position: 4}, + duration: 0, }, { - description: "test out of order units, hours cannot follow minutes", - input: "1d 5m 1h", - error: "invalid unit order", + name: "out of order units, hours cannot follow minutes", + input: "1d5m1h", + expectedErr: &haproxytime.SyntaxError{Msg: "invalid unit order", Position: 5}, + duration: 0, }, { - description: "test skipped units", - input: "1d 100us", + name: "skipped units", + input: "1d100us", + expectedErr: nil, duration: 24*time.Hour + 100*time.Microsecond, }, { - description: "test maximum number of seconds", - input: "9223372036s", - duration: 9223372036 * time.Second, - }, { - description: "test overflow", - input: "9223372036s 1000ms", - error: "overflow", + name: "maximum number of ms", + input: "2147483647", + expectedErr: nil, + duration: 2147483647 * time.Millisecond, }, { - description: "test underflow", - input: "9223372037s", - error: "underflow", + name: "maximum number expressed with all units", + input: "24d20h31m23s647ms0us", + expectedErr: nil, + duration: 2147483647 * time.Millisecond, }} - 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) - return - } - if got != tc.duration { - t.Errorf("%q: wanted value %q, got %q", tc.input, tc.duration, got) - } - }) - } -} - -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{ - "", - "0", - "a", - "/", - "100.d", - "1d 30mgarbage", - "1d 3h 30m 45s 100ms 200us", - "1d3h30m45s100ms200us", - " 1d 3h 30m 45s ", - "5000", - "5000 100ms", - "5000 100ms", - "000000000000000000000001 01us", - "0ms", - "0d 0h 0m 0s 0ms 0us", - "0d 0h 0m 0s 0 0us", - "0d 0h 0m 0s 0ms 0", - "0d 0h 0m 0s 1", - "0ms 0ms", - "1d 5m 1h", - "1d 100us", - "9223372036s", - "9223372036s 1000ms", - "9223372037s", - } + parserConfig, _ := haproxytime.NewParserConfig(haproxytime.UnitMillisecond, haproxytime.ModeMultiUnit) for _, tc := range testCases { - f.Add(tc) - } + duration, err := haproxytime.ParseDurationWithConfig(tc.input, parserConfig) - f.Fuzz(func(t *testing.T, input string) { - _, _, err := haproxytime.ParseDuration(input) - if err != nil { - t.Skip() + // Validate the Is functionality for errors. + var se *haproxytime.SyntaxError + + if tc.expectedErr != nil { + if !(errors.As(err, &se) && errors.Is(err, &haproxytime.SyntaxError{})) { + t.Errorf("Is check failed for SyntaxError, expected %T, but got %T", &haproxytime.SyntaxError{}, err) + } else { + var expected *haproxytime.SyntaxError + if !errors.As(tc.expectedErr, &expected) { + t.Error("failed to convert expected error to SyntaxError") + return + } + if se.Msg != expected.Msg || se.Position != expected.Position { + t.Errorf("expected SyntaxError fields %v, but got %v", *expected, *se) + } + } + } else { + if err != nil { + t.Errorf("Didn't expect error for input %q but got %v", tc.input, err) + continue + } + if duration != tc.duration { + t.Errorf("expected duration %v, but got %v", tc.duration, duration) + } } - }) + } } 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