From d71504ad9fc39b735689da32fa0be8a63c2cc7f8 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 19 Aug 2026 09:26:17 +0200 Subject: fix(render): omit DTEND at the domain's own last day, 9999-12-31 F1: Date.add_days is UNBOUNDED (date.mli) -- only Date.make enforces 1583..9999 -- and Date.to_iso8601 pads but never truncates, so 9999-12-31's naive successor formatted as "10000-01-01", and compact turned that into a 9-digit, non-conformant DATE on the last VEVENT of year 9999. Confirmed at the source before fixing, and reproduced against real `colitur emit --format ics --from 9999 --to 9999` output (DTEND;VALUE=DATE:100000101) before touching any code. RFC 5545 section 3.6.1: a VEVENT with a DATE-valued DTSTART and neither DTEND nor DURATION has an implicit one-day duration, so omitting DTEND for that one event is the standard's own correct answer, not a workaround. dtend_of re-derives the successor's year/month/day and re-validates them through Date.make -- the one function that actually enforces the domain -- before trusting the string; None means the caller omits the DTEND line entirely. F2 (minor, same function): documented next_day's own Error branch as dead-but-silent on shipped data (event's iso <> "" guard is the only caller and always parses) -- behaviour unchanged, comment only. Two new tests: the domain's last VEVENT (DTSTART 99991231) has no DTEND line at all; every DTEND anywhere in a 9999 feed is exactly 8 digits (the general form of the bug, catches a regression anywhere else in the domain too). Existing 2027/2028 DTEND-arithmetic assertions untouched and still pass. Mutation-proved: both new tests fail against the pre-fix code (9-digit DTEND value caught verbatim), pass after. --- test/test_ics.ml | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) (limited to 'test') diff --git a/test/test_ics.ml b/test/test_ics.ml index ac0c9ee..7d1ca37 100644 --- a/test/test_ics.ml +++ b/test/test_ics.ml @@ -88,6 +88,47 @@ let test_dtstamp_is_a_parameter () = Alcotest.(check int) "default is deterministic" 365 (count_prefix "DTSTAMP:20270101T000000Z" (lines (out_2027 ()))) +(* F1 (fix round 1, 2026-08-19): 9999 is the domain's own last civil year. + Its last VEVENT's successor date (10000-01-01) falls outside the + 1583..9999 domain [Date.make] enforces (date.mli) -- [Date.add_days] is + itself unbounded, and [to_iso8601] pads but never truncates, so the naive + successor string would be 9 digits, not 8. RFC 5545 section 3.6.1 makes a + DATE-valued DTSTART with neither DTEND nor DURATION mean exactly one day, + so [dtend_of] omits the DTEND line entirely for that one event rather than + emit it malformed. *) +let out_9999 () = Ics.year (Test_view.view_of 9999) + +let test_dtend_omitted_at_domain_end () = + let ls = Array.of_list (lines (out_9999 ())) in + let n = Array.length ls in + let idx = ref (-1) in + Array.iteri (fun i l -> if l = "DTSTART;VALUE=DATE:99991231" then idx := i) ls; + if !idx < 0 then Alcotest.fail "DTSTART;VALUE=DATE:99991231 not found"; + let next_line = if !idx + 1 < n then ls.(!idx + 1) else "" in + Alcotest.(check bool) "no DTEND line follows DTSTART 99991231" true + (count_prefix "DTEND;VALUE=DATE:" [ next_line ] = 0); + Alcotest.(check bool) "SUMMARY follows DTSTART directly instead" true + (count_prefix "SUMMARY:" [ next_line ] = 1) + +(* The general form of F1's bug class: every DTEND value anywhere in the + feed must be an 8-digit YYYYMMDD, never 9 (a future domain-boundary + regression anywhere else in 1583..9999 would show up here too). *) +let test_all_9999_dtends_are_eight_digits () = + let ls = lines (out_9999 ()) in + let prefix = "DTEND;VALUE=DATE:" in + let ends = + List.filter_map + (fun l -> + if count_prefix prefix [ l ] = 1 then + Some (String.sub l (String.length prefix) (String.length l - String.length prefix)) + else None) + ls + in + Alcotest.(check bool) "at least one DTEND present in 9999" true (List.length ends > 0); + List.iter + (fun v -> if String.length v <> 8 then Alcotest.failf "DTEND value %S is %d digits, not 8" v (String.length v)) + ends + let suite = ( "Emit/ics", [ Alcotest.test_case "envelope" `Quick test_envelope; @@ -97,4 +138,6 @@ let suite = Alcotest.test_case "folded and CRLF" `Quick test_every_line_folded_and_crlf; Alcotest.test_case "no RRULE" `Quick test_no_rrule; Alcotest.test_case "text escaped" `Quick test_text_escaped; - Alcotest.test_case "DTSTAMP is a parameter" `Quick test_dtstamp_is_a_parameter ] ) + Alcotest.test_case "DTSTAMP is a parameter" `Quick test_dtstamp_is_a_parameter; + Alcotest.test_case "DTEND omitted at domain end" `Quick test_dtend_omitted_at_domain_end; + Alcotest.test_case "all 9999 DTENDs are eight digits" `Quick test_all_9999_dtends_are_eight_digits ] ) -- cgit v1.3