diff --git a/internal/config/config.go b/internal/config/config.go index e6b7359..9ad5d45 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -182,6 +182,15 @@ func (c Config) HistoryCapacity() int { return capacity } +// maxHistoryCapacity bounds HistoryCapacity(). NewRingBuffer allocates its +// capacity eagerly for each of the 7 scalar history series, so an +// unreasonably large window/interval ratio (e.g. a config typo like +// history_window_minutes: 525600 with poll_interval_seconds: 0.05) would +// otherwise allocate on the order of gigabytes at startup and OOM a Pi. One +// million points per series (~7M points, well under 1 GiB total) is far more +// than any real dashboard use case needs. +const maxHistoryCapacity = 1_000_000 + // validLogLevels are the log levels newLogger understands; any other value // silently falls back to info, so we reject it here instead. var validLogLevels = map[string]bool{ @@ -208,6 +217,15 @@ func (c Config) Validate() error { if c.HistoryWindowMinutes <= 0 { return fmt.Errorf("history_window_minutes must be > 0 (got %v)", c.HistoryWindowMinutes) } + // Compare the ratio in float64 before any int conversion: converting an + // out-of-range float64 to int is implementation-specific in Go and + // differs by architecture (e.g. amd64 wraps to math.MinInt64, arm64 + // saturates to math.MaxInt64), so calling HistoryCapacity() first and + // comparing the resulting int could let an extreme ratio slip past this + // check on some platforms but not others. + if ratio := c.HistoryWindowMinutes * 60 / c.PollIntervalSeconds; ratio > maxHistoryCapacity { + return fmt.Errorf("history_window_minutes (%v) / poll_interval_seconds (%v) implies %v history points per series, exceeding the %d limit; increase poll_interval_seconds or reduce history_window_minutes", c.HistoryWindowMinutes, c.PollIntervalSeconds, ratio, maxHistoryCapacity) + } if c.HistoryPersistEnabled && c.DataDir == "" { return fmt.Errorf("data_dir must not be empty when history_persist_enabled is true") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 335757a..05e9fa2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -232,6 +232,19 @@ func TestValidate_RejectsBadValues(t *testing.T) { {"disk warn above crit", func(c *Config) { c.Thresholds.DiskWarnPercent = 99 }}, {"swap warn above crit", func(c *Config) { c.Thresholds.SwapWarnPercent = 99 }}, {"memory warn above crit", func(c *Config) { c.Thresholds.MemoryWarnPercent = 99 }}, + {"history capacity exceeds sanity cap", func(c *Config) { + c.HistoryWindowMinutes = 525600 + c.PollIntervalSeconds = 0.05 + }}, + {"history capacity ratio overflows int64", func(c *Config) { + // Regression test: converting an out-of-range float64 to int is + // implementation-specific in Go and differs by architecture + // (amd64 wraps to a negative number, arm64 saturates to + // math.MaxInt64), so the cap must be checked on the float64 + // ratio, not on HistoryCapacity()'s post-conversion int. + c.HistoryWindowMinutes = 1e300 + c.PollIntervalSeconds = 1 + }}, {"negative alerts for_seconds", func(c *Config) { c.Alerts.ForSeconds = -1 }}, {"negative notify max retries", func(c *Config) { c.Alerts.NotifyMaxRetries = -1 }}, {"negative notify backoff", func(c *Config) { c.Alerts.NotifyRetryBackoffSeconds = -1 }}, @@ -255,6 +268,23 @@ func TestValidate_RejectsBadValues(t *testing.T) { } } +func TestValidate_HistoryCapacityBoundary(t *testing.T) { + cfg := Default() + // With poll_interval_seconds: 60, HistoryCapacity() equals + // history_window_minutes exactly, giving clean integer boundaries. + cfg.PollIntervalSeconds = 60 + + cfg.HistoryWindowMinutes = maxHistoryCapacity // exactly at the cap + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() rejected history capacity exactly at the cap: %v", err) + } + + cfg.HistoryWindowMinutes = maxHistoryCapacity + 1 // one point over + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() accepted history capacity one point over the cap") + } +} + func TestValidate_AcceptsValidEdgeCases(t *testing.T) { cfg := Default() // Warn equal to crit and a zero stale threshold are both allowed.