diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-13 23:19:36 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-13 23:19:36 -0500 |
| commit | 00f1246fa32fa65571a141b1b685b95994fbfbc9 (patch) | |
| tree | c86af3372d6e4c019aeaa782b44d011a4f8e10b5 | |
| parent | 9927367c02c4e8723af27efb1dedbb8f89149c85 (diff) | |
| download | dotemacs-00f1246fa32fa65571a141b1b685b95994fbfbc9.tar.gz dotemacs-00f1246fa32fa65571a141b1b685b95994fbfbc9.zip | |
fix(mu4e-attachments): reject stale MIME handles at save time
The selection buffer captures MIME handles when it opens, but viewing another message kills the buffer a handle points at. Saving through such a handle died deep in mm-decode, or could write another message's bytes. The save path now signals a user-error naming the attachment when its handle's buffer is dead, and it checks before any mu4e MIME support loads, in the same spot as the existing no-handle check.
I added tests for the stale-handle error, the healthy live-handle save, and the mid-batch failure behavior (the error propagates and files already written stay).
| -rw-r--r-- | modules/mu4e-attachments.el | 12 | ||||
| -rw-r--r-- | tests/test-mu4e-attachments.el | 50 |
2 files changed, 61 insertions, 1 deletions
diff --git a/modules/mu4e-attachments.el b/modules/mu4e-attachments.el index 6c2be6fb..56b96b87 100644 --- a/modules/mu4e-attachments.el +++ b/modules/mu4e-attachments.el @@ -100,11 +100,21 @@ size; an unknown candidate annotates as nil so marginalia shows nothing." (require 'mu4e-mime-parts))) (defun cj/mu4e--save-attachment-part (part directory) - "Save attachment PART to DIRECTORY and return the final path." + "Save attachment PART to DIRECTORY and return the final path. +Signals a `user-error' when PART's MIME handle is stale: a handle's car +is the buffer holding the part's bytes, and viewing another message kills +it, so saving through it would error deep in mm-decode or write another +message's content. The staleness check runs before +`cj/mu4e--ensure-attachment-save-functions', like the no-handle check." (let ((handle (plist-get part :handle))) (unless handle (user-error "Attachment has no MIME handle: %s" (or (plist-get part :filename) "<unnamed>"))) + (when (and (consp handle) + (bufferp (car handle)) + (not (buffer-live-p (car handle)))) + (user-error "Attachment %s is stale (the message view changed) -- reopen the message and save again" + (or (plist-get part :filename) "<unnamed>"))) (cj/mu4e--ensure-attachment-save-functions) (let* ((path (funcall mu4e-uniquify-save-file-name-function (mu4e-join-paths directory diff --git a/tests/test-mu4e-attachments.el b/tests/test-mu4e-attachments.el index 0a780977..986c2746 100644 --- a/tests/test-mu4e-attachments.el +++ b/tests/test-mu4e-attachments.el @@ -75,6 +75,56 @@ so this fails the same way whether or not mu4e's MIME support is loadable." (should-error (cj/mu4e--save-attachment-part part "/downloads") :type 'user-error))) +(ert-deftest test-mu4e-attachments-save-part-errors-on-stale-handle () + "Error: a handle whose MIME buffer was killed fails with a clear error. +The selection buffer captures handles when it opens; a real MIME handle's +car is the buffer holding the part's bytes, and viewing another message +kills it. Saving through it must signal a `user-error' naming the file, +not die in `mm-save-part-to-file' (or save another message's bytes)." + (let* ((dead (generate-new-buffer "stale-mime-part")) + (part (test-mu4e-attachments--part "invoice.pdf" 3 (list dead)))) + (kill-buffer dead) + (should-error (cj/mu4e--save-attachment-part part "/downloads") + :type 'user-error) + (condition-case err + (cj/mu4e--save-attachment-part part "/downloads") + (user-error (should (string-match-p "invoice\\.pdf" (cadr err))))))) + +(ert-deftest test-mu4e-attachments-save-part-live-buffer-handle-saves () + "Normal: a handle whose MIME buffer is alive saves normally." + (let* ((live (generate-new-buffer "live-mime-part")) + (part (test-mu4e-attachments--part "invoice.pdf" 3 (list live))) + (mu4e-uniquify-save-file-name-function #'identity) + saved) + (unwind-protect + (cl-letf (((symbol-function 'mu4e-join-paths) + (lambda (&rest pieces) (mapconcat #'identity pieces "/"))) + ((symbol-function 'mm-save-part-to-file) + (lambda (_handle path) (setq saved path)))) + (should (equal (cj/mu4e--save-attachment-part part "/downloads") + "/downloads/invoice.pdf")) + (should (equal saved "/downloads/invoice.pdf"))) + (kill-buffer live)))) + +(ert-deftest test-mu4e-attachments-save-parts-mid-batch-failure-propagates () + "Error: a mid-batch save failure propagates; earlier parts stay saved. +Characterizes the batch path: no silent skip of the failing part, and the +files already written are not rolled back." + (let ((parts (list (test-mu4e-attachments--part "a.pdf" 1) + (test-mu4e-attachments--part "b.pdf" 2) + (test-mu4e-attachments--part "c.pdf" 3))) + (saved '())) + (cl-letf (((symbol-function 'cj/mu4e--save-attachment-part) + (lambda (part _dir) + (let ((name (plist-get part :filename))) + (when (equal name "b.pdf") + (user-error "Stale handle: %s" name)) + (push name saved) + name)))) + (should-error (cj/mu4e--save-attachment-parts parts "/downloads") + :type 'user-error)) + (should (equal (nreverse saved) '("a.pdf"))))) + (ert-deftest test-mu4e-attachments-save-all-prompts-once () "Normal: the save-all command prompts for a directory once and saves all parts." (let ((parts (list (test-mu4e-attachments--part "a.pdf" 1) |
