aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/render/emit_ics.ml44
-rw-r--r--test/test_ics.ml45
2 files changed, 86 insertions, 3 deletions
diff --git a/lib/render/emit_ics.ml b/lib/render/emit_ics.ml
index e1a5ad0..41efb95 100644
--- a/lib/render/emit_ics.ml
+++ b/lib/render/emit_ics.ml
@@ -12,8 +12,43 @@ let compact iso = (* "2027-01-13" -> "20270113" *)
let next_day iso =
match Colitur_kernel.Date.of_iso8601 iso with
| Ok d -> Colitur_kernel.Date.to_iso8601 (Colitur_kernel.Date.add_days d 1)
+ (* Dead on shipped data: [event]'s own [iso <> ""] guard is the only
+ caller, and every real [iso] it passes came from [Date.to_iso8601] in
+ the first place, so it always parses. Left total rather than raising
+ (the kernel/render determinism rule), but a caller relying on this
+ branch would silently get DTEND == DTSTART -- a zero-length event --
+ with no diagnostic, if it were ever actually reached. Documented, not
+ fixed: nothing exercises it. *)
| Error _ -> iso
+(* RFC 5545 section 3.6.1: a VEVENT with a DATE-valued DTSTART and NEITHER
+ DTEND NOR DURATION has an implicit duration of exactly one day, so
+ omitting DTEND for the domain's own last day is the standard's own
+ correct way to say precisely what we mean -- not a workaround.
+
+ Needed because [Date.add_days] is itself UNBOUNDED (date.mli: "may
+ denote a year outside 1583..9999 -- only [make] enforces the domain"):
+ the successor of 9999-12-31 is a real [Date.t] that [next_day] above
+ happily renders as "10000-01-01" (date.ml's [to_iso8601] pads with
+ [Printf.sprintf "%04d-..."] but never truncates), which [compact] would
+ turn into a 9-digit, non-conformant DATE. [Date.make] is the ONE kernel
+ function that actually enforces 1583..9999 (date.mli), so this takes
+ [next_day]'s own successor string, re-derives its year/month/day, and
+ re-validates THOSE through [make] before trusting the string at all.
+ [None] means "one day past the domain's own last day"; the caller omits
+ DTEND entirely rather than clamp, truncate, or fall back to DURATION. *)
+let dtend_of iso =
+ let next = next_day iso in
+ match String.split_on_char '-' next with
+ | [ y; m; d ] -> (
+ match (int_of_string_opt y, int_of_string_opt m, int_of_string_opt d) with
+ | Some year, Some month, Some day -> (
+ match Colitur_kernel.Date.make ~year ~month ~day with
+ | Ok _ -> Some next
+ | Error _ -> None)
+ | _ -> None)
+ | _ -> None
+
let esc x = Escape.apply Escape.Ics x
let line b l = Buffer.add_string b (Escape.fold_ics l)
@@ -41,8 +76,13 @@ let event b ~rite ~dtstamp d =
line b (Printf.sprintf "UID:%s-%s@colitur" (compact iso) rite);
line b ("DTSTAMP:" ^ dtstamp);
line b ("DTSTART;VALUE=DATE:" ^ compact iso);
- (* RFC 5545 section 3.6.1: DTEND is EXCLUSIVE for an all-day event. *)
- line b ("DTEND;VALUE=DATE:" ^ compact (next_day iso));
+ (* RFC 5545 section 3.6.1: DTEND is EXCLUSIVE for an all-day event -- and
+ omitted entirely, rather than emitted malformed, for the one event
+ whose successor falls outside the engine's own 1583..9999 domain
+ (see [dtend_of] above). *)
+ (match dtend_of iso with
+ | Some next -> line b ("DTEND;VALUE=DATE:" ^ compact next)
+ | None -> ());
line b ("SUMMARY:" ^ esc summary);
if desc <> "" then line b ("DESCRIPTION:" ^ esc desc);
line b "TRANSP:TRANSPARENT";
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 ] )