Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/configuration/arguments/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,10 @@ Environment Variable: WATCHTOWER_TIMEOUT
Default: 30s
```

!!! Note
Bare numeric values (e.g., `60` or `1.5`) without a time unit are interpreted as seconds.
Using a unit suffix (`s`, `m`, etc.) is recommended and required for other time units.

### Cooldown Delay

Sets a global minimum image age before Watchtower will perform the update.
Expand Down
47 changes: 47 additions & 0 deletions internal/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -597,6 +598,8 @@

// envDuration fetches a duration from an environment variable.
//
// Bare values without a time unit are treated as seconds.
//
// Parameters:
// - key: Environment variable key.
//
Expand All @@ -605,9 +608,53 @@
func envDuration(key string) time.Duration {
viper.MustBindEnv(key)

// Check the raw env var so bare numbers are treated as seconds before
// viper/cast turns them into nanoseconds.
if raw := os.Getenv(key); raw != "" {
trimmed := strings.TrimSpace(raw)
if isPureNumeric(trimmed) {
val, err := strconv.ParseFloat(trimmed, 64)
if err == nil {
return time.Duration(val * float64(time.Second))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

return viper.GetDuration(key)
}

// isPureNumeric reports whether str is a bare number (integer or float,
// possibly signed) with no duration unit characters.
func isPureNumeric(str string) bool {

Check warning on line 628 in internal/flags/flags.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

internal/flags/flags.go#L628

Method isPureNumeric has a cyclomatic complexity of 10 (limit is 8)
if str == "" {
return false
}

sawDigit := false
sawDot := false

for i, char := range str {
switch {
case char >= '0' && char <= '9':
sawDigit = true
case char == '.':
if sawDot {
return false
}

sawDot = true
case char == '-' || char == '+':
if i != 0 {
return false
}
default:
return false
}
}

return sawDigit
}

// filterEmptyStrings removes empty or whitespace-only strings from a slice.
//
// Parameters:
Expand Down
91 changes: 91 additions & 0 deletions internal/flags/flags_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package flags

import (
"math"
"strconv"
"testing"
)

// FuzzIsPureNumeric verifies that isPureNumeric never panics and that
// when it returns true, strconv.ParseFloat either succeeds with a finite
// value or fails only with a range error.
func FuzzIsPureNumeric(f *testing.F) {
// Valid bare numbers.
f.Add("0")
f.Add("42")
f.Add("300")
f.Add("1.5")
f.Add(".5")
f.Add("1.")
f.Add("-10")
f.Add("+3")
f.Add("-1.5")
f.Add("+.5")
f.Add("-.5")
f.Add("+0")
f.Add("-0")

// Invalid: multiple dots and misplaced signs.
f.Add("1.2.3")
f.Add("1..2")
f.Add("1-2")
f.Add("12+")
f.Add("1+2")
f.Add("5-")
f.Add("+-1")
f.Add("-+5")
f.Add("++3")

// Other invalid cases (units, letters, control chars, long inputs, etc.).
f.Add("")
f.Add(".")
f.Add("+")
f.Add("-")
f.Add("+.")
f.Add("-.5")
f.Add("1a")
f.Add("1e3")
f.Add("1E-3")
f.Add("Inf")
f.Add("NaN")
f.Add("\x00")
f.Add("1\x002")
f.Add("¹²³") // Unicode digits — must be rejected
f.Add("1,000")
f.Add(" 42")
f.Add("1.2.3.4.5")
f.Add(string(make([]byte, 1024))) // long string of zeros (will be filled in fuzzer)

// Duration-like inputs.
f.Add("30s")
f.Add("2m")
f.Add("1h")
f.Add("1d")

f.Fuzz(func(t *testing.T, input string) {
result := isPureNumeric(input)

if result {
// When true, ParseFloat must succeed with finite value or fail only on range.
val, err := strconv.ParseFloat(input, 64)
if err != nil {
numErr, ok := err.(*strconv.NumError)
if ok && numErr.Err == strconv.ErrRange {
return // out-of-range magnitude is acceptable
}

t.Errorf("isPureNumeric(%q) = true, but strconv.ParseFloat returned non-range error: %v", input, err)

return
}

if math.IsNaN(val) || math.IsInf(val, 0) {
t.Errorf("isPureNumeric(%q) = true, but parsed value %v is NaN or Inf", input, val)
}
}

// We do not assert the inverse (ParseFloat success ⇒ isPureNumeric true)
// because we intentionally reject scientific notation, Inf, NaN, etc.
// Those should fall through to normal duration parsing.
})
}
155 changes: 155 additions & 0 deletions internal/flags/flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"regexp"
"strings"
"testing"
"time"

"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -1723,3 +1724,157 @@
})
}
}

// TestEnvDuration_LegacyBareNumberAsSeconds verifies that bare numeric
// values supplied for WATCHTOWER_TIMEOUT are interpreted as seconds.
func TestEnvDuration_LegacyBareNumberAsSeconds(t *testing.T) {

Check warning on line 1730 in internal/flags/flags_test.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

internal/flags/flags_test.go#L1730

Method TestEnvDuration_LegacyBareNumberAsSeconds has 76 lines of code (limit is 50)
tests := []struct {
name string
envValue string // "" means ensure unset for default case
expected time.Duration
}{
{
name: "default (no env) is 30s",
envValue: "",
expected: 30 * time.Second,
},
{
name: "bare integer as seconds (common legacy)",
envValue: "60",
expected: 60 * time.Second,
},
{
name: "larger bare integer",
envValue: "300",
expected: 300 * time.Second,
},
{
name: "bare float seconds",
envValue: "1.5",
expected: 1500 * time.Millisecond,
},
{
name: "with explicit unit s",
envValue: "45s",
expected: 45 * time.Second,
},
{
name: "with unit m",
envValue: "2m",
expected: 2 * time.Minute,
},
{
name: "zero value",
envValue: "0",
expected: 0,
},
{
name: "negative (parsed as negative; validation in preRun will fatal)",
envValue: "-10",
expected: -10 * time.Second,
},
{
name: "invalid non-numeric (viper fallback to zero)",
envValue: "abc",
expected: 0,
},
{
name: "invalid multiple decimal points (viper fallback to zero)",
envValue: "12.34.56",
expected: 0,
},
{
name: "positive sign prefix",
envValue: "+10",
expected: 10 * time.Second,
},
{
name: "whitespace trimmed by envDuration",
envValue: " 30 ",
expected: 30 * time.Second,
},
{
name: "very large integer (overflow fallback to zero)",
envValue: "1" + strings.Repeat("0", 1000),
expected: 0,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.envValue != "" {
t.Setenv("WATCHTOWER_TIMEOUT", tc.envValue)
} else {
_ = os.Unsetenv("WATCHTOWER_TIMEOUT")
}

// Register (with t.Setenv isolation) to capture value via (patched) envDuration at flag creation time
SetDefaults()

cmd := &cobra.Command{}
RegisterSystemFlags(cmd)

got, err := cmd.PersistentFlags().GetDuration("stop-timeout")
require.NoError(t, err)
assert.Equal(t, tc.expected, got)
})
}
}

// TestIsPureNumeric verifies isPureNumeric behavior with table-driven cases.
func TestIsPureNumeric(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
// Valid cases
{name: "zero", input: "0", want: true},
{name: "positive integer", input: "42", want: true},
{name: "large integer (legacy common case)", input: "300", want: true},
{name: "simple float", input: "1.5", want: true},
{name: "leading dot", input: ".5", want: true},
{name: "trailing dot", input: "1.", want: true},
{name: "negative integer", input: "-10", want: true},
{name: "explicit positive", input: "+3", want: true},
{name: "negative float", input: "-1.5", want: true},
{name: "positive leading dot", input: "+.5", want: true},
{name: "negative leading dot", input: "-.5", want: true},

// Invalid: no digits
{name: "empty string", input: "", want: false},
{name: "just decimal point", input: ".", want: false},
{name: "just plus sign", input: "+", want: false},
{name: "just minus sign", input: "-", want: false},
{name: "plus and dot only", input: "+.", want: false},
{name: "minus and dot only", input: "-.", want: false},

// Invalid: multiple dots
{name: "multiple dots", input: "1.2.3", want: false},
{name: "double dot", input: "1..2", want: false},
{name: "three dots", input: "1.2.3.4", want: false},

// Invalid: misplaced or multiple signs
{name: "minus after digit", input: "1-2", want: false},
{name: "plus at end", input: "12+", want: false},
{name: "plus in middle", input: "1+2", want: false},
{name: "trailing minus", input: "5-", want: false},
{name: "multiple signs at start", input: "+-1", want: false},
{name: "mixed signs", input: "-+5", want: false},

// Invalid: other characters
{name: "contains letter", input: "1a", want: false},
{name: "scientific notation", input: "1e3", want: false},
{name: "thousands separator", input: "1,000", want: false},
{name: "duration unit seconds", input: "30s", want: false},
{name: "duration unit minutes", input: "2m", want: false},
{name: "embedded space", input: "1 2", want: false},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := isPureNumeric(tc.input)
assert.Equal(t, tc.want, got, "isPureNumeric(%q)", tc.input)
})
}
}
Loading