From eaf82fca3f28b42952efcde62e2ca7dd5d93a146 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 01:27:28 +0000 Subject: [PATCH 1/2] Reject a sub-tick --for and report an overflowing one as too long [patch] TryParseAnyLength checked that the duration was positive on the double, before TimeSpan.FromSeconds rounded "0.00000001s" down to zero, so the tool started and stopped at once. It also returned false for a value past TimeSpan.MaxValue, so "--for 20000000d" was reported as "not a duration" rather than "too long". Check the converted TimeSpan for at least one tick, and return TimeSpan.MaxValue for a value too long to hold so the caller's upper bound reports it. Fixes #57 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3e2AZDcKPKkvjAga2 --- TrayApp.Test/CommandLineParserTests.cs | 13 ++++++++-- TrayApp.Test/DurationParserTests.cs | 24 ++++++++++++++++++ TrayApp/Cli/DurationParser.cs | 35 +++++++++++++++++++++++--- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/TrayApp.Test/CommandLineParserTests.cs b/TrayApp.Test/CommandLineParserTests.cs index c685c21..b2b05cc 100644 --- a/TrayApp.Test/CommandLineParserTests.cs +++ b/TrayApp.Test/CommandLineParserTests.cs @@ -68,9 +68,18 @@ public void TryParse_WithAnUnreadableDuration_Fails() } [TestMethod] - public void TryParse_WithADurationLongerThanATimerCanWait_FailsWithAUsageError() + public void TryParse_WithADurationUnderATick_Fails() { - Assert.IsFalse(CommandLineParser.TryParse(["--for", "25d"], NoOptions, out _, out string error)); + Assert.IsFalse(CommandLineParser.TryParse(["--for", "0.00000001s"], NoOptions, out _, out string error)); + Assert.Contains("is not a duration", error, StringComparison.Ordinal); + } + + [TestMethod] + [DataRow("25d")] + [DataRow("20000000d")] + public void TryParse_WithADurationLongerThanATimerCanWait_FailsWithAUsageError(string text) + { + Assert.IsFalse(CommandLineParser.TryParse(["--for", text], NoOptions, out _, out string error)); Assert.Contains("--for can be at most 24 days", error, StringComparison.Ordinal); } diff --git a/TrayApp.Test/DurationParserTests.cs b/TrayApp.Test/DurationParserTests.cs index f584e9a..5357f0d 100644 --- a/TrayApp.Test/DurationParserTests.cs +++ b/TrayApp.Test/DurationParserTests.cs @@ -64,6 +64,8 @@ public void TryParse_WithABareNumberAfterAUnit_ReturnsFalse() [DataRow("5x")] [DataRow("m5")] [DataRow("1e9h")] + [DataRow("0.00000001s")] + [DataRow("0.00000004s")] public void TryParse_WithAnInvalidDuration_ReturnsFalse(string? text) { Assert.IsFalse(DurationParser.TryParse(text, out TimeSpan duration)); @@ -81,6 +83,8 @@ public void TryParse_AtTheLongestWaitATimerTakes_ReturnsTheDuration() [TestMethod] [DataRow("25d")] [DataRow("24d21h")] + [DataRow("20000000d")] + [DataRow("99999999999999999999999999999999999999999999999999999999999999999999h")] public void TryParse_LongerThanATimerCanWait_ReturnsFalse(string text) { // ManualResetEventSlim.Wait and the tray's timer both throw past int.MaxValue milliseconds, about @@ -89,6 +93,26 @@ public void TryParse_LongerThanATimerCanWait_ReturnsFalse(string text) Assert.AreEqual(TimeSpan.Zero, duration); } + [TestMethod] + [DataRow("0.00000001s")] + [DataRow("0.00000004s")] + public void TryParseAnyLength_WithADurationThatRoundsToZero_ReturnsFalse(string text) + { + // The value is positive, but under one tick, so it would be a zero-length run. + Assert.IsFalse(DurationParser.TryParseAnyLength(text, out TimeSpan duration)); + Assert.AreEqual(TimeSpan.Zero, duration); + } + + [TestMethod] + [DataRow("20000000d")] + [DataRow("99999999999999999999999999999999999999999999999999999999999999999999h")] + public void TryParseAnyLength_WithADurationBeyondTimeSpan_ReturnsTheLongestTimeSpan(string text) + { + // It is a duration, just one too long to hold, so the caller reports it as too long. + Assert.IsTrue(DurationParser.TryParseAnyLength(text, out TimeSpan duration)); + Assert.AreEqual(TimeSpan.MaxValue, duration); + } + [TestMethod] public void MaxDuration_IsTheLongestTimeoutAWaitTakes() { diff --git a/TrayApp/Cli/DurationParser.cs b/TrayApp/Cli/DurationParser.cs index 531bf9b..b5b2cdd 100644 --- a/TrayApp/Cli/DurationParser.cs +++ b/TrayApp/Cli/DurationParser.cs @@ -52,7 +52,10 @@ public static bool TryParse(string? text, out TimeSpan duration) /// /// The text to read. /// Receives the duration when parsing succeeds. - /// when is a positive duration. + /// + /// when is a duration of at least one tick. A duration too long + /// for to hold is returned as . + /// internal static bool TryParseAnyLength(string? text, out TimeSpan duration) { duration = TimeSpan.Zero; @@ -78,12 +81,38 @@ internal static bool TryParseAnyLength(string? text, out TimeSpan duration) sawAnyPart = true; } - if (!sawAnyPart || totalSeconds <= 0 || totalSeconds > TimeSpan.MaxValue.TotalSeconds) + if (!sawAnyPart || totalSeconds <= 0) + { + return false; + } + + // A value TimeSpan cannot hold is still a duration, just one too long for anything to wait out, so it + // is reported as the longest TimeSpan and the caller's upper bound turns it away as too long. + if (totalSeconds >= TimeSpan.MaxValue.TotalSeconds) + { + duration = TimeSpan.MaxValue; + return true; + } + + TimeSpan converted; + try + { + converted = TimeSpan.FromSeconds(totalSeconds); + } + catch (OverflowException) + { + duration = TimeSpan.MaxValue; + return true; + } + + // The check above ran on the double: a value under one tick passes it and then rounds to zero, which + // would start the tool and stop it in the same moment. + if (converted <= TimeSpan.Zero) { return false; } - duration = TimeSpan.FromSeconds(totalSeconds); + duration = converted; return true; } From 2d3ad0df02528cc0101007b378cc2161459f24c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 01:36:44 +0000 Subject: [PATCH 2/2] Drop the unreachable OverflowException catch from TryParseAnyLength TimeSpan.FromSeconds does not throw for any value below the TimeSpan.MaxValue.TotalSeconds guard that precedes it (it does not throw at the bound itself either), so the catch was dead code and left the new lines short of the coverage gate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3e2AZDcKPKkvjAga2 --- TrayApp/Cli/DurationParser.cs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/TrayApp/Cli/DurationParser.cs b/TrayApp/Cli/DurationParser.cs index b5b2cdd..e5824bc 100644 --- a/TrayApp/Cli/DurationParser.cs +++ b/TrayApp/Cli/DurationParser.cs @@ -94,16 +94,7 @@ internal static bool TryParseAnyLength(string? text, out TimeSpan duration) return true; } - TimeSpan converted; - try - { - converted = TimeSpan.FromSeconds(totalSeconds); - } - catch (OverflowException) - { - duration = TimeSpan.MaxValue; - return true; - } + TimeSpan converted = TimeSpan.FromSeconds(totalSeconds); // The check above ran on the double: a value under one tick passes it and then rounds to zero, which // would start the tool and stop it in the same moment.