routerrpc: add parseDuration helper for relative time specs
What changed, and why it matters
This commit adds a new helper function that lets users write time spans in more human-friendly ways (like "-30d" for 30 days ago or "-1y" for one year ago). It is purely additive code with tests and does not change any existing behavior. There is no indication it fixes a security bug or introduces a vulnerability.
No security action required. Review as normal code-quality change.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change introduces parseDuration() in lnrpc/routerrpc/parse_duration.go, extending Go’s time.ParseDuration with d/w/M/y units. It enforces negative-only durations and includes unit and property tests. No callers are added or modified in this commit, and no security relevance is stated.
Changed components
lnrpc/routerrpc/parse_duration.golnrpc/routerrpc/parse_duration_test.goInspect captured patch +353 / −0
diff --git a/lnrpc/routerrpc/parse_duration.go b/lnrpc/routerrpc/parse_duration.go
new file mode 100644
index 0000000..b523d22
--- /dev/null
+++ b/lnrpc/routerrpc/parse_duration.go
@@ -0,0 +1,109 @@
+package routerrpc
+
+import (
+ "fmt"
+ "time"
+)
+
+// parseDuration parses a duration string using a hybrid approach. It first
+// attempts to use the standard library time.ParseDuration, which supports
+// ns, us, ms, s, m, h. If that fails, it falls back to custom parsing for
+// user-friendly units: d (days), w (weeks), M (months), y (years).
+//
+// Examples:
+// - Standard Go: "-24h", "-1.5h", "-30m"
+// - Custom units: "-1d", "-1w", "-1M", "-1y"
+//
+// All durations should be negative to indicate "time ago".
+func parseDuration(durationStr string) (time.Duration, error) {
+ // First, try the standard library parser.
+ duration, err := time.ParseDuration(durationStr)
+ if err == nil {
+ // Enforce negative durations to prevent confusion.
+ if duration >= 0 {
+ return 0, fmt.Errorf("duration must be negative to " +
+ "indicate time in the past (e.g., -1w, -24h)")
+ }
+
+ return duration, nil
+ }
+
+ // Fall back to custom parsing for d, w, M, y units.
+ if len(durationStr) < 2 {
+ return 0, fmt.Errorf("duration too short")
+ }
+
+ // Duration strings should start with a minus sign for "ago".
+ if durationStr[0] != '-' {
+ return 0, fmt.Errorf("duration must be " +
+ "negative (e.g., -1w, -24h)")
+ }
+
+ // Strip the minus sign.
+ durationStr = durationStr[1:]
+
+ // Find where the numeric part ends. We allow digits and a single
+ // decimal point so that custom units accept fractional values like
+ // "-1.5d", matching the behaviour of the standard Go parser.
+ var (
+ numStr string
+ unit string
+ hasDot bool
+ )
+ for i, ch := range durationStr {
+ if ch == '.' && !hasDot {
+ hasDot = true
+ continue
+ }
+ if ch < '0' || ch > '9' {
+ numStr = durationStr[:i]
+ unit = durationStr[i:]
+ break
+ }
+ }
+
+ if numStr == "" {
+ return 0, fmt.Errorf("no numeric value found")
+ }
+ if unit == "" {
+ return 0, fmt.Errorf("no unit specified")
+ }
+
+ var value float64
+ _, parseErr := fmt.Sscanf(numStr, "%f", &value)
+ if parseErr != nil {
+ return 0, fmt.Errorf("invalid numeric value: %w", parseErr)
+ }
+
+ // Calculate the duration based on the custom unit.
+ var customDuration time.Duration
+ switch unit {
+ case "d":
+ customDuration = time.Duration(value * 24 * float64(time.Hour))
+
+ case "w":
+ customDuration = time.Duration(
+ value * 7 * 24 * float64(time.Hour),
+ )
+
+ case "M":
+ // Average month = 30.44 days.
+ customDuration = time.Duration(
+ value * 30.44 * 24 * float64(time.Hour),
+ )
+
+ case "y":
+ // Average year = 365.25 days.
+ customDuration = time.Duration(
+ value * 365.25 * 24 * float64(time.Hour),
+ )
+
+ default:
+ // Not a custom unit we recognize, return the original error.
+ return 0, fmt.Errorf("unknown time unit: %s (supported: ns, "+
+ "us, ms, s, m, h, d, w, M, y)", unit)
+ }
+
+ // Return negative duration (going back in time).
+ return -customDuration, nil
+}
diff --git a/lnrpc/routerrpc/parse_duration_test.go b/lnrpc/routerrpc/parse_duration_test.go
new file mode 100644
index 0000000..d36c8be
--- /dev/null
+++ b/lnrpc/routerrpc/parse_duration_test.go
@@ -0,0 +1,244 @@
+package routerrpc
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "pgregory.net/rapid"
+)
+
+// TestParseDuration tests the hybrid duration parsing with explicit examples.
+func TestParseDuration(t *testing.T) {
+ t.Parallel()
+
+ //nolint:ll
+ tests := []struct {
+ name string
+ input string
+ expected time.Duration
+ wantErr bool
+ }{
+ // Standard Go durations.
+ {
+ name: "standard go hours",
+ input: "-24h",
+ expected: -24 * time.Hour,
+ },
+ {
+ name: "standard go fractional hours",
+ input: "-1.5h",
+ expected: time.Duration(-1.5 * float64(time.Hour)),
+ },
+ {
+ name: "standard go minutes",
+ input: "-30m",
+ expected: -30 * time.Minute,
+ },
+ {
+ name: "standard go seconds",
+ input: "-60s",
+ expected: -60 * time.Second,
+ },
+ {
+ name: "standard go milliseconds",
+ input: "-500ms",
+ expected: -500 * time.Millisecond,
+ },
+ {
+ name: "standard go microseconds",
+ input: "-1000us",
+ expected: -1000 * time.Microsecond,
+ },
+ {
+ name: "standard go complex",
+ input: "-2h30m45s",
+ expected: -(2*time.Hour + 30*time.Minute + 45*time.Second),
+ },
+
+ // Custom units.
+ {
+ name: "custom days",
+ input: "-1d",
+ expected: -24 * time.Hour,
+ },
+ {
+ name: "custom multiple days",
+ input: "-7d",
+ expected: -7 * 24 * time.Hour,
+ },
+ {
+ name: "custom weeks",
+ input: "-1w",
+ expected: -7 * 24 * time.Hour,
+ },
+ {
+ name: "custom multiple weeks",
+ input: "-4w",
+ expected: -4 * 7 * 24 * time.Hour,
+ },
+ {
+ name: "custom months",
+ input: "-1M",
+ expected: time.Duration(-30.44 * 24 * float64(time.Hour)),
+ },
+ {
+ name: "custom years",
+ input: "-1y",
+ expected: time.Duration(-365.25 * 24 * float64(time.Hour)),
+ },
+
+ // Error cases.
+ {
+ name: "positive duration",
+ input: "1h",
+ wantErr: true,
+ },
+ {
+ name: "no minus sign custom",
+ input: "1d",
+ wantErr: true,
+ },
+ {
+ name: "empty string",
+ input: "",
+ wantErr: true,
+ },
+ {
+ name: "just minus",
+ input: "-",
+ wantErr: true,
+ },
+ {
+ name: "no number",
+ input: "-d",
+ wantErr: true,
+ },
+ {
+ name: "no unit",
+ input: "-5",
+ wantErr: true,
+ },
+ {
+ name: "invalid unit",
+ input: "-1x",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := parseDuration(tt.input)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+
+ require.NoError(t, err)
+ require.Equal(t, tt.expected, got)
+ })
+ }
+}
+
+// TestParseDurationProperties uses property-based testing to verify invariants.
+func TestParseDurationProperties(t *testing.T) {
+ t.Parallel()
+
+ // Test that all valid standard Go durations work.
+ t.Run("standard go durations", func(t *testing.T) {
+ rapid.Check(t, func(rt *rapid.T) {
+ // Generate a random duration string using
+ // time.Duration's String() method.
+ hours := rapid.IntRange(-8760, -1).Draw(rt, "hours")
+ minutes := rapid.IntRange(0, 59).Draw(rt, "minutes")
+ seconds := rapid.IntRange(0, 59).Draw(rt, "seconds")
+
+ d := time.Duration(hours)*time.Hour +
+ time.Duration(minutes)*time.Minute +
+ time.Duration(seconds)*time.Second
+
+ // Parse it back.
+ parsed, err := parseDuration(d.String())
+ require.NoError(rt, err)
+
+ // Should match original (within a small margin for
+ // float precision).
+ diff := parsed - d
+ if diff < 0 {
+ diff = -diff
+ }
+ require.Less(
+ rt, diff, time.Microsecond,
+ "parsed duration differs: got %v, want %v",
+ parsed, d,
+ )
+ })
+ })
+
+ // Test that custom units produce negative durations.
+ t.Run("custom units negative", func(t *testing.T) {
+ rapid.Check(t, func(rt *rapid.T) {
+ value := rapid.IntRange(1, 1000).Draw(rt, "value")
+ unit := rapid.SampledFrom([]string{"d", "w", "M", "y"}).
+ Draw(rt, "unit")
+
+ input := fmt.Sprintf("-%d%s", value, unit)
+
+ parsed, err := parseDuration(input)
+ require.NoError(rt, err)
+ require.Less(
+ rt, parsed, time.Duration(0),
+ "custom unit should produce negative duration",
+ )
+ })
+ })
+
+ // Test that days are always 24 hours.
+ t.Run("days invariant", func(t *testing.T) {
+ rapid.Check(t, func(rt *rapid.T) {
+ days := rapid.IntRange(1, 365).Draw(rt, "days")
+ input := fmt.Sprintf("-%dd", days)
+
+ parsed, err := parseDuration(input)
+ require.NoError(rt, err)
+
+ expected := time.Duration(-days) * 24 * time.Hour
+ require.Equal(rt, expected, parsed)
+ })
+ })
+
+ // Test that weeks are always 7 days.
+ t.Run("weeks invariant", func(t *testing.T) {
+ rapid.Check(t, func(rt *rapid.T) {
+ weeks := rapid.IntRange(1, 52).Draw(rt, "weeks")
+ input := fmt.Sprintf("-%dw", weeks)
+
+ parsed, err := parseDuration(input)
+ require.NoError(rt, err)
+
+ expected := time.Duration(-weeks) * 7 * 24 * time.Hour
+ require.Equal(rt, expected, parsed)
+ })
+ })
+
+ // Test that positive durations always error.
+ t.Run("positive durations error", func(t *testing.T) {
+ rapid.Check(t, func(rt *rapid.T) {
+ value := rapid.IntRange(1, 1000).Draw(rt, "value")
+ unit := rapid.SampledFrom([]string{
+ "s", "m", "h", "d", "w", "M", "y",
+ }).Draw(rt, "unit")
+
+ // Positive duration (no minus sign).
+ input := fmt.Sprintf("%d%s", value, unit)
+
+ _, err := parseDuration(input)
+ require.Error(rt, err,
+ "positive duration should error: %s", input)
+ })
+ })
+}
Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.