aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/calendar-sync.el42
-rw-r--r--tests/test-calendar-sync--robustness.el70
-rw-r--r--tests/test-calendar-sync.el9
3 files changed, 104 insertions, 17 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 7ed14921f..c0e0e935a 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -255,8 +255,10 @@ Example: -21600 → `UTC-6' or `UTC-6:00'."
(dir (file-name-directory calendar-sync--state-file)))
(unless (file-directory-p dir)
(make-directory dir t))
- (with-temp-file calendar-sync--state-file
- (prin1 state (current-buffer)))))
+ (let ((tmp (make-temp-file (expand-file-name ".calendar-sync-state-" dir))))
+ (with-temp-file tmp
+ (prin1 state (current-buffer)))
+ (rename-file tmp calendar-sync--state-file t))))
(defun calendar-sync--load-state ()
"Load sync state from disk."
@@ -1248,11 +1250,19 @@ RECURRENCE-ID exceptions are applied to override specific occurrences."
(time-less-p (calendar-sync--event-start-time a)
(calendar-sync--event-start-time b)))))
(org-entries (mapcar #'calendar-sync--event-to-org sorted-events)))
- (if org-entries
- (concat "# Calendar Events\n\n"
- (string-join org-entries "\n\n")
- "\n")
- nil)))
+ ;; Distinguish a healthy zero-event calendar from garbage: a real
+ ;; iCalendar (carries BEGIN:VCALENDAR) with no in-window events
+ ;; returns the header alone, so the caller writes an empty calendar
+ ;; and reports success. Non-iCalendar content (an HTML error page, a
+ ;; truncated download) has no VCALENDAR and returns nil -- a failure.
+ (cond
+ (org-entries
+ (concat "# Calendar Events\n\n"
+ (string-join org-entries "\n\n")
+ "\n"))
+ ((string-match-p "BEGIN:VCALENDAR" ics-content)
+ "# Calendar Events\n\n")
+ (t nil))))
(error
(calendar-sync--log-silently "calendar-sync: Parse error: %s" (error-message-string err))
nil)))
@@ -1271,7 +1281,7 @@ invoked when the fetch completes, either successfully or with an error."
(make-process
:name "calendar-sync-curl"
:buffer buffer
- :command (list "curl" "-s" "-L"
+ :command (list "curl" "-s" "-L" "--fail"
"--connect-timeout" "10"
"--max-time" (number-to-string calendar-sync-fetch-timeout)
url)
@@ -1303,7 +1313,7 @@ owns deleting the temp file after a successful callback."
(make-process
:name "calendar-sync-curl"
:buffer buffer
- :command (list "curl" "-s" "-L"
+ :command (list "curl" "-s" "-L" "--fail"
"--connect-timeout" "10"
"--max-time" (number-to-string calendar-sync-fetch-timeout)
"-o" temp-file
@@ -1329,13 +1339,17 @@ owns deleting the temp file after a successful callback."
(funcall callback nil))))
(defun calendar-sync--write-file (content file)
- "Write CONTENT to FILE.
-Creates parent directories if needed."
+ "Write CONTENT to FILE atomically.
+Creates parent directories if needed, then writes a temp file in the same
+directory and renames it into place, so org-agenda or chime reading mid-write
+never sees a half-written calendar."
(let ((dir (file-name-directory file)))
(unless (file-directory-p dir)
- (make-directory dir t)))
- (with-temp-file file
- (insert content)))
+ (make-directory dir t))
+ (let ((tmp (make-temp-file (expand-file-name ".calendar-sync-" dir))))
+ (with-temp-file tmp
+ (insert content))
+ (rename-file tmp file t))))
(defun calendar-sync--emacs-binary ()
"Return the Emacs executable to use for calendar conversion workers."
diff --git a/tests/test-calendar-sync--robustness.el b/tests/test-calendar-sync--robustness.el
new file mode 100644
index 000000000..2c044b013
--- /dev/null
+++ b/tests/test-calendar-sync--robustness.el
@@ -0,0 +1,70 @@
+;;; test-calendar-sync--robustness.el --- Tests for sync robustness fixes -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for two robustness fixes:
+;; - calendar-sync--parse-ics distinguishes a healthy zero-event calendar
+;; (a real iCalendar with no in-window events -> non-nil header) from
+;; garbage (no BEGIN:VCALENDAR -> nil), so a near-empty calendar no longer
+;; reports "parse failed".
+;; - calendar-sync--write-file writes atomically (temp file + rename), so a
+;; reader never sees a half-written calendar and no temp file is left behind.
+;; (The curl --fail change is in the make-process command list and is exercised
+;; against the live feed, not unit-tested here.)
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; calendar-sync--parse-ics: zero-event vs garbage
+
+(ert-deftest test-calendar-sync--parse-ics-valid-zero-events-non-nil ()
+ "Normal: a real iCalendar with no in-window events returns a non-nil empty
+calendar, not a parse failure."
+ (let ((result (calendar-sync--parse-ics "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n")))
+ (should result)
+ (should (string-match-p "Calendar Events" result))))
+
+(ert-deftest test-calendar-sync--parse-ics-garbage-nil ()
+ "Error: non-iCalendar content (no BEGIN:VCALENDAR, e.g. an HTML error page)
+returns nil -- a genuine failure."
+ (should-not (calendar-sync--parse-ics "HTTP 404 Not Found\n<html><body>error</body></html>")))
+
+;;; calendar-sync--write-file: atomic
+
+(ert-deftest test-calendar-sync--write-file-writes-content ()
+ "Normal: the content lands in the target file."
+ (let* ((dir (make-temp-file "cal-sync-test-" t))
+ (file (expand-file-name "agenda.org" dir)))
+ (unwind-protect
+ (progn
+ (calendar-sync--write-file "# Calendar Events\n\nhello\n" file)
+ (should (equal "# Calendar Events\n\nhello\n"
+ (with-temp-buffer (insert-file-contents file)
+ (buffer-string)))))
+ (delete-directory dir t))))
+
+(ert-deftest test-calendar-sync--write-file-leaves-no-temp ()
+ "Boundary: the temp file is renamed into place, not left in the directory."
+ (let* ((dir (make-temp-file "cal-sync-test-" t))
+ (file (expand-file-name "agenda.org" dir)))
+ (unwind-protect
+ (progn
+ (calendar-sync--write-file "x" file)
+ ;; only the target file remains -- no leftover .calendar-sync-* temp
+ (should (equal '("agenda.org")
+ (directory-files dir nil "\\`[^.]"))))
+ (delete-directory dir t))))
+
+(ert-deftest test-calendar-sync--write-file-creates-parent-dir ()
+ "Boundary: a missing parent directory is created."
+ (let* ((root (make-temp-file "cal-sync-test-" t))
+ (file (expand-file-name "sub/nested/agenda.org" root)))
+ (unwind-protect
+ (progn
+ (calendar-sync--write-file "y" file)
+ (should (file-exists-p file)))
+ (delete-directory root t))))
+
+(provide 'test-calendar-sync--robustness)
+;;; test-calendar-sync--robustness.el ends here
diff --git a/tests/test-calendar-sync.el b/tests/test-calendar-sync.el
index 62b00aba1..f562cfc61 100644
--- a/tests/test-calendar-sync.el
+++ b/tests/test-calendar-sync.el
@@ -471,11 +471,14 @@ Earlier events should appear first in the output."
(should (string-match-p "\\* Event 1" org-content))
(should (string-match-p "\\* Event 2" org-content))))
-(ert-deftest test-calendar-sync--parse-ics-boundary-empty-calendar-returns-nil ()
- "Test parsing empty calendar (no events)."
+(ert-deftest test-calendar-sync--parse-ics-boundary-empty-calendar-returns-header ()
+ "A valid but empty iCalendar (no events) is a healthy zero-event calendar:
+it returns a non-nil header so the sync reports success, not a parse failure.
+Garbage with no BEGIN:VCALENDAR still returns nil (covered elsewhere)."
(let* ((ics "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR")
(org-content (calendar-sync--parse-ics ics)))
- (should (null org-content))))
+ (should org-content)
+ (should (string-match-p "Calendar Events" org-content))))
(ert-deftest test-calendar-sync--parse-ics-error-malformed-ics-returns-nil ()
"Test that malformed .ics returns nil and sets error."