From f610c2fef97381c7ecfc9dca0a0e1a39a16abe18 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 3 May 2026 23:51:59 -0500 Subject: fix: sanitize calendar event headings and property values `calendar-sync--event-to-org` already cleaned the description body via `calendar-sync--sanitize-org-body`, but the event summary went into the heading line and the location, organizer, status, and URL went into the property drawer without sanitization. Any of those fields containing newlines could create extra Org headings, close the property drawer early with a stray `:END:`, or inject property-looking lines that the agenda would then parse as real properties. I added two helpers. `calendar-sync--sanitize-org-property-value` trims the input and collapses any run of whitespace or newlines into a single space. `calendar-sync--sanitize-org-heading` composes that over the existing body sanitizer so `*` sequences also become `-`. The event-to-org function now routes the summary through the heading sanitizer and each property value through the property sanitizer. I added regression tests across two files. `test-calendar-sync--sanitize-org-body.el` gets 4 new tests for the two helpers, covering newline flattening, leading-star replacement, structural-character flattening, and whitespace collapse. `test-calendar-sync--event-to-org.el` gets 2 new integration tests. A summary containing `\n** Hidden task` produces a single `* ` heading with the body inlined. A location containing `\n:END:\n* Not a real heading` collapses to a single property line with no extra `:END:` or heading injected. 515 calendar-sync tests pass together. --- modules/calendar-sync.el | 33 ++++++++++++++++--- tests/test-calendar-sync--event-to-org.el | 44 ++++++++++++++++++++++++++ tests/test-calendar-sync--sanitize-org-body.el | 24 ++++++++++++++ 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el index b9cc57f4..06bee213 100644 --- a/modules/calendar-sync.el +++ b/modules/calendar-sync.el @@ -287,6 +287,20 @@ parsed as org headings. Handles multiple levels (e.g. ** becomes --)." (concat (make-string (length (match-string 1 match)) ?-) " ")) text))) +(defun calendar-sync--sanitize-org-property-value (text) + "Sanitize TEXT for safe inclusion as a single Org property value." + (when text + (string-trim + (replace-regexp-in-string + "[[:space:]\n\r]+" + " " + text)))) + +(defun calendar-sync--sanitize-org-heading (text) + "Sanitize TEXT for safe inclusion as a single Org heading title." + (calendar-sync--sanitize-org-property-value + (calendar-sync--sanitize-org-body text))) + ;;; Date Utilities (defun calendar-sync--add-months (date months) @@ -1059,7 +1073,8 @@ Cleans text fields (description, location, summary) via `calendar-sync--clean-te "Convert parsed EVENT plist to org entry string. Produces property drawer with LOCATION, ORGANIZER, STATUS, URL when present. Description appears as body text after the drawer." - (let* ((summary (or (plist-get event :summary) "(No Title)")) + (let* ((summary (calendar-sync--sanitize-org-heading + (or (plist-get event :summary) "(No Title)"))) (description (plist-get event :description)) (location (plist-get event :location)) (start (plist-get event :start)) @@ -1072,16 +1087,24 @@ Description appears as body text after the drawer." (props '())) ;; Collect non-nil properties (when (and location (not (string-empty-p location))) - (push (format ":LOCATION: %s" location) props)) + (push (format ":LOCATION: %s" + (calendar-sync--sanitize-org-property-value location)) + props)) (when organizer (let ((org-name (or (plist-get organizer :cn) (plist-get organizer :email)))) (when org-name - (push (format ":ORGANIZER: %s" org-name) props)))) + (push (format ":ORGANIZER: %s" + (calendar-sync--sanitize-org-property-value org-name)) + props)))) (when (and status (not (string-empty-p status))) - (push (format ":STATUS: %s" status) props)) + (push (format ":STATUS: %s" + (calendar-sync--sanitize-org-property-value status)) + props)) (when (and url (not (string-empty-p url))) - (push (format ":URL: %s" url) props)) + (push (format ":URL: %s" + (calendar-sync--sanitize-org-property-value url)) + props)) (setq props (nreverse props)) ;; Build output (let ((parts (list timestamp (format "* %s" summary)))) diff --git a/tests/test-calendar-sync--event-to-org.el b/tests/test-calendar-sync--event-to-org.el index a3dc0106..9f87856f 100644 --- a/tests/test-calendar-sync--event-to-org.el +++ b/tests/test-calendar-sync--event-to-org.el @@ -11,6 +11,16 @@ (require 'testutil-calendar-sync) (require 'calendar-sync) +(defun test-calendar-sync--count-line-matches (regexp text) + "Count lines in TEXT that match REGEXP." + (with-temp-buffer + (insert text) + (goto-char (point-min)) + (let ((count 0)) + (while (re-search-forward regexp nil t) + (setq count (1+ count))) + count))) + ;;; Normal Cases (ert-deftest test-calendar-sync--event-to-org-normal-all-fields () @@ -108,6 +118,40 @@ ;; Only the event heading should use * (should (= 1 (length (split-string result "^\\* " t))))))) +(ert-deftest test-calendar-sync--event-to-org-boundary-summary-structure-is-flattened () + "Test event summary cannot create additional Org headings." + (let* ((start (test-calendar-sync-time-days-from-now 5 14 0)) + (end (test-calendar-sync-time-days-from-now 5 15 0)) + (event (list :summary "* Planning\n** Hidden task" + :start start + :end end))) + (let ((result (calendar-sync--event-to-org event))) + (should (string-match-p "^\\* - Planning -- Hidden task$" result)) + (should-not (string-match-p "^\\*\\* Hidden task" result)) + (should (= 1 (length (split-string result "^\\* " t))))))) + +(ert-deftest test-calendar-sync--event-to-org-boundary-property-structure-is-flattened () + "Test property values cannot create extra drawer lines or close the drawer." + (let* ((start (test-calendar-sync-time-days-from-now 5 14 0)) + (end (test-calendar-sync-time-days-from-now 5 15 0)) + (event (list :summary "Meeting" + :start start + :end end + :location "Room 1\n:END:\n* Not a real heading" + :organizer (list :cn "Jane\n:STATUS: fake" + :email "jane@example.com") + :status "accepted\n:LOCATION: fake" + :url "https://example.com/a\n:PROPERTIES:"))) + (let ((result (calendar-sync--event-to-org event))) + (should (string-match-p ":LOCATION: Room 1 :END: \\* Not a real heading" result)) + (should (string-match-p ":ORGANIZER: Jane :STATUS: fake" result)) + (should (string-match-p ":STATUS: accepted :LOCATION: fake" result)) + (should (string-match-p ":URL: https://example.com/a :PROPERTIES:" result)) + (should (= 1 (test-calendar-sync--count-line-matches "^:LOCATION:" result))) + (should (= 1 (test-calendar-sync--count-line-matches "^:STATUS:" result))) + (should (= 1 (test-calendar-sync--count-line-matches "^:END:$" result))) + (should-not (string-match-p "^\\* Not a real heading" result))))) + (ert-deftest test-calendar-sync--event-to-org-boundary-organizer-email-only () "Test organizer without CN shows email." (let* ((start (test-calendar-sync-time-days-from-now 5 14 0)) diff --git a/tests/test-calendar-sync--sanitize-org-body.el b/tests/test-calendar-sync--sanitize-org-body.el index c38c277e..636946db 100644 --- a/tests/test-calendar-sync--sanitize-org-body.el +++ b/tests/test-calendar-sync--sanitize-org-body.el @@ -70,5 +70,29 @@ "Lone asterisk with space at start of line is sanitized." (should (equal "- " (calendar-sync--sanitize-org-body "* ")))) +;;; Heading and Property Sanitizers + +(ert-deftest test-calendar-sync--sanitize-org-heading-flattens-newlines () + "Heading text should stay on one Org heading line." + (should (equal "Planning Agenda" + (calendar-sync--sanitize-org-heading "Planning\nAgenda")))) + +(ert-deftest test-calendar-sync--sanitize-org-heading-replaces-leading-stars () + "Heading text should not start with Org heading stars." + (should (equal "- Planning -- Hidden" + (calendar-sync--sanitize-org-heading "* Planning\n** Hidden")))) + +(ert-deftest test-calendar-sync--sanitize-org-property-value-flattens-structure () + "Property values should not create extra property drawer lines." + (should (equal "Room 1 :END: * Not a heading" + (calendar-sync--sanitize-org-property-value + "Room 1\n:END:\n* Not a heading")))) + +(ert-deftest test-calendar-sync--sanitize-org-property-value-trims-and-collapses () + "Property values should be compact single-line values." + (should (equal "alpha beta gamma" + (calendar-sync--sanitize-org-property-value + " alpha\t beta\n\n gamma ")))) + (provide 'test-calendar-sync--sanitize-org-body) ;;; test-calendar-sync--sanitize-org-body.el ends here -- cgit v1.2.3