diff --git a/CHANGELOG.md b/CHANGELOG.md index e021cde..0afbd62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased - Run developer checks once with the existing 90% coverage gate, pin CI to Swift 6.2 and current Node/pnpm tooling, and execute universal CLI smoke checks in CI. +- Reject out-of-range numeric reminder indexes before converting them to array offsets, preventing an arithmetic-overflow crash for the minimum integer. +- Honor the supplied calendar's time zone when RemindCore parses local dates, while retaining explicit ISO 8601 offsets. ## 0.3.6 - 2026-09-07 diff --git a/Sources/RemindCore/DateParsing.swift b/Sources/RemindCore/DateParsing.swift index 90e322e..f90cba0 100644 --- a/Sources/RemindCore/DateParsing.swift +++ b/Sources/RemindCore/DateParsing.swift @@ -19,6 +19,7 @@ public enum DateParsing { parseUserDateWithMetadata(input, now: now, calendar: calendar)?.date } + /// Interprets local dates in the supplied calendar's time zone; explicit ISO offsets take precedence. public static func parseUserDateWithMetadata( _ input: String, now: Date = Date(), @@ -39,15 +40,15 @@ public enum DateParsing { } let localISO = - localISOFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSSSSS").date(from: trimmed) - ?? localISOFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSS").date(from: trimmed) - ?? localISOFormatter(format: "yyyy-MM-dd'T'HH:mm:ss").date(from: trimmed) - ?? localISOFormatter(format: "yyyy-MM-dd'T'HH:mm").date(from: trimmed) + localFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", calendar: calendar).date(from: trimmed) + ?? localFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSS", calendar: calendar).date(from: trimmed) + ?? localFormatter(format: "yyyy-MM-dd'T'HH:mm:ss", calendar: calendar).date(from: trimmed) + ?? localFormatter(format: "yyyy-MM-dd'T'HH:mm", calendar: calendar).date(from: trimmed) if let localISO { return ParsedUserDate(date: localISO, isDateOnly: false) } - for (formatter, isDateOnly) in dateFormatters() { + for (formatter, isDateOnly) in dateFormatters(calendar: calendar) { if let date = formatter.date(from: trimmed) { return ParsedUserDate(date: date, isDateOnly: isDateOnly) } @@ -91,15 +92,15 @@ public enum DateParsing { return formatter } - private static func localISOFormatter(format: String) -> DateFormatter { + private static func localFormatter(format: String, calendar: Calendar) -> DateFormatter { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone.current + formatter.timeZone = calendar.timeZone formatter.dateFormat = format return formatter } - private static func dateFormatters() -> [(DateFormatter, Bool)] { + private static func dateFormatters(calendar: Calendar) -> [(DateFormatter, Bool)] { let formats: [(String, Bool)] = [ ("yyyy-MM-dd", true), ("yyyy-MM-dd HH:mm", false), @@ -110,11 +111,7 @@ public enum DateParsing { ("dd-MM-yyyy", true), ] return formats.map { format, isDateOnly in - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone.current - formatter.dateFormat = format - return (formatter, isDateOnly) + (localFormatter(format: format, calendar: calendar), isDateOnly) } } } diff --git a/Sources/RemindCore/IDResolver.swift b/Sources/RemindCore/IDResolver.swift index 3d715ed..cf366be 100644 --- a/Sources/RemindCore/IDResolver.swift +++ b/Sources/RemindCore/IDResolver.swift @@ -14,11 +14,10 @@ public enum IDResolver { for input in inputs { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) if let index = Int(trimmed) { - let idx = index - 1 - guard idx >= 0 && idx < numericSorted.count else { + guard index > 0 && index <= numericSorted.count else { throw RemindCoreError.invalidIdentifier(trimmed) } - resolved.append(numericSorted[idx]) + resolved.append(numericSorted[index - 1]) continue } diff --git a/Tests/RemindCoreTests/DateParsingTests.swift b/Tests/RemindCoreTests/DateParsingTests.swift index 497f114..c884d71 100644 --- a/Tests/RemindCoreTests/DateParsingTests.swift +++ b/Tests/RemindCoreTests/DateParsingTests.swift @@ -38,6 +38,26 @@ struct DateParsingTests { #expect(parsed?.isDateOnly == false) } + @Test("Local dates honor the supplied time zone", arguments: [14 * 3600, -10 * 3600]) + func suppliedTimeZone(_ offset: Int) throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: offset)) + let expected = try #require( + calendar.date(from: DateComponents(year: 2026, month: 1, day: 3, hour: 12, minute: 34))) + + for input in ["2026-01-03T12:34", "2026-01-03 12:34", "01/03/2026 12:34"] { + let parsed = try #require(DateParsing.parseUserDateWithMetadata(input, calendar: calendar)) + #expect(parsed.date == expected) + #expect(!parsed.isDateOnly) + } + let allDay = try #require(DateParsing.parseUserDateWithMetadata("2026-01-03", calendar: calendar)) + #expect(allDay.date == calendar.startOfDay(for: expected)) + #expect(allDay.isDateOnly) + + let absolute = "2026-01-03T12:34:00Z" + #expect(DateParsing.parseUserDate(absolute, calendar: calendar) == ISO8601DateFormatter().date(from: absolute)) + } + @Test("Formatted date parsing") func formattedParsing() { let input = "2026-01-03 10:30" diff --git a/Tests/RemindCoreTests/IDResolverTests.swift b/Tests/RemindCoreTests/IDResolverTests.swift index 0cefe8f..30fe1a6 100644 --- a/Tests/RemindCoreTests/IDResolverTests.swift +++ b/Tests/RemindCoreTests/IDResolverTests.swift @@ -45,6 +45,14 @@ struct IDResolverTests { #expect(resolved.first?.title == "Second") } + @Test("Reject out-of-range indexes without overflow", arguments: [Int.min, -1, 0, 3, Int.max]) + func rejectInvalidIndex(_ index: Int) { + let input = String(index) + #expect(throws: RemindCoreError.invalidIdentifier(input)) { + _ = try IDResolver.resolve([input], from: sampleReminders()) + } + } + @Test("Resolve by prefix") func resolvePrefix() throws { let resolved = try IDResolver.resolve(["abcd"], from: sampleReminders()) diff --git a/docs/commands.md b/docs/commands.md index 86fc9f9..4f2dcaf 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -72,6 +72,7 @@ remindctl edit 4A83 --no-repeat ``` `edit`, `complete`, and `delete` accept indexes from the current default listing or ID prefixes. +Numeric indexes must be positive and within the current view; out-of-range values return an error. If `add`, `edit`, or `complete` reports `Reminder is missing a calendar`, EventKit saved the reminder but returned it without its list. Check Reminders.app before retrying; this error does not roll back the saved change.