diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-19 09:26:17 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-19 09:26:17 +0200 |
| commit | d71504ad9fc39b735689da32fa0be8a63c2cc7f8 (patch) | |
| tree | 12c4f51d6808e04b2305ee8ed3f455eb9ee245c6 /lib | |
| parent | decb13fb17697f6d6d952c407f2173a714164804 (diff) | |
| download | colitur-d71504ad9fc39b735689da32fa0be8a63c2cc7f8.tar.gz colitur-d71504ad9fc39b735689da32fa0be8a63c2cc7f8.zip | |
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.
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/render/emit_ics.ml | 44 |
1 files changed, 42 insertions, 2 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"; |
