From b5a5e9e3fdf01dda6940eaa1ab0967194711aa58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 16:32:05 +0000 Subject: [PATCH] Refuse a timer-backed timing longer than about 49.7 days [patch] FetchTimeout, DiffTimeout, ProbeTimeout and MaintenanceInterval were only checked to be positive, but they are handed to CancellationTokenSource, Task.WaitAsync and PeriodicTimer, which throw ArgumentOutOfRangeException past uint.MaxValue - 1 milliseconds. A value such as 365.00:00:00 meant as "effectively no timeout" passed validation and then stopped startup with a raw exception, or made every request needing a fetch or diff fail with a 500. The validator now refuses it with a message naming the setting, the value and the limit. Fixes #121 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5wqHMdBMn6Wino1eH --- ...itBranchStateCacheOptionsValidatorTests.cs | 63 +++++++++++++++++++ .../GitBranchStateCacheOptionsValidator.cs | 33 ++++++++-- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/GitBranchStateCache.Tests/Configuration/GitBranchStateCacheOptionsValidatorTests.cs b/GitBranchStateCache.Tests/Configuration/GitBranchStateCacheOptionsValidatorTests.cs index 6f6ffd5..3ae0f1d 100644 --- a/GitBranchStateCache.Tests/Configuration/GitBranchStateCacheOptionsValidatorTests.cs +++ b/GitBranchStateCache.Tests/Configuration/GitBranchStateCacheOptionsValidatorTests.cs @@ -210,6 +210,69 @@ public void Validate_NonPositiveTimeout_Fails(string setting) Assert.IsTrue(Validate(options).Failed); } + private static void SetTimerSetting(GitBranchStateCacheOptions options, string setting, TimeSpan value) + { + switch (setting) + { + case "FetchTimeout": + options.FetchTimeout = value; + break; + case "DiffTimeout": + options.DiffTimeout = value; + break; + case "ProbeTimeout": + options.ProbeTimeout = value; + break; + default: + options.MaintenanceInterval = value; + break; + } + } + + [TestMethod] + [DataRow("FetchTimeout")] + [DataRow("DiffTimeout")] + [DataRow("ProbeTimeout")] + [DataRow("MaintenanceInterval")] + public void Validate_TimerSettingOf49Days_Succeeds(string setting) + { + GitBranchStateCacheOptions options = Valid(); + SetTimerSetting(options, setting, TimeSpan.FromDays(49)); + + Assert.IsTrue(Validate(options).Succeeded); + } + + [TestMethod] + [DataRow("FetchTimeout")] + [DataRow("DiffTimeout")] + [DataRow("ProbeTimeout")] + [DataRow("MaintenanceInterval")] + public void Validate_TimerSettingOf50Days_Fails(string setting) + { + // Past uint.MaxValue - 1 ms, CancellationTokenSource, Task.WaitAsync and PeriodicTimer throw, so a + // value meant as "effectively no timeout" has to be refused here, naming the setting. + GitBranchStateCacheOptions options = Valid(); + SetTimerSetting(options, setting, TimeSpan.FromDays(50)); + + ValidateOptionsResult result = Validate(options); + + Assert.IsTrue(result.Failed); + Assert.Contains(setting, result.FailureMessage); + Assert.Contains(GitBranchStateCacheOptionsValidator.MaxTimerDuration.ToString(), result.FailureMessage); + } + + [TestMethod] + public void MaxTimerDuration_IsTheLongestDurationTheTimersAccept() + { + TimeSpan max = GitBranchStateCacheOptionsValidator.MaxTimerDuration; + + using CancellationTokenSource accepted = new(max); + using PeriodicTimer timer = new(max); + TimeSpan tooLong = max + TimeSpan.FromMilliseconds(1); + Assert.ThrowsExactly(() => new CancellationTokenSource(tooLong)); + Assert.ThrowsExactly(() => new PeriodicTimer(tooLong)); + } + [TestMethod] public void Validate_ZeroIdleMaxAge_Succeeds() { diff --git a/GitBranchStateCache/Configuration/GitBranchStateCacheOptionsValidator.cs b/GitBranchStateCache/Configuration/GitBranchStateCacheOptionsValidator.cs index b96b012..4d7f18f 100644 --- a/GitBranchStateCache/Configuration/GitBranchStateCacheOptionsValidator.cs +++ b/GitBranchStateCache/Configuration/GitBranchStateCacheOptionsValidator.cs @@ -16,6 +16,12 @@ namespace ktsu.GitBranchStateCache.Configuration; /// public sealed class GitBranchStateCacheOptionsValidator : IValidateOptions { + /// + /// The longest duration , + /// and accept: uint.MaxValue - 1 milliseconds, about 49.7 days. + /// + internal static readonly TimeSpan MaxTimerDuration = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + /// public ValidateOptionsResult Validate(string? name, GitBranchStateCacheOptions options) { @@ -115,10 +121,10 @@ private static void ValidateTimings(GitBranchStateCacheOptions options, List } } + /// + /// Requires a setting that is handed to a .NET timer to be positive and within + /// . + /// + /// + /// Without the upper bound, a value such as 365.00:00:00 meant as "effectively no timeout" + /// passed validation and then threw at startup or on every + /// request that needed a fetch or a diff, rather than producing a message naming the setting. + /// + private static void RequirePositiveTimerDuration(TimeSpan value, string setting, List failures) + { + RequirePositive(value, setting, failures); + if (value > MaxTimerDuration) + { + failures.Add( + $"{GitBranchStateCacheOptions.SectionName}:{setting} ({value}) must not exceed {MaxTimerDuration}, the longest timeout .NET timers accept."); + } + } + /// /// Reports whether a bound URL is absolute and addressable over HTTP. ///