diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-16 11:30:04 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-16 11:30:04 -0500 |
| commit | 244d4c56768fcc60bd1b23fe45df7a57c7b293ec (patch) | |
| tree | 3a83c5953f7c963c3f3ab7b28044d6decd6ec2fb /tests/test-update-text-file.el | |
| parent | b35d10bae7315fe3e497f1188bbe5ce86cef1bbf (diff) | |
| download | dotemacs-244d4c56768fcc60bd1b23fe45df7a57c7b293ec.tar.gz dotemacs-244d4c56768fcc60bd1b23fe45df7a57c7b293ec.zip | |
feat(gptel-tools): harden path validation with file-truename realpath
Resolves PATH through file-truename before applying home-directory and
read/write checks across the path-handling tools (git_status, git_log,
git_diff, move_to_trash, read_text_file, update_text_file,
write_text_file, list_directory_files, read_buffer, web_fetch).
Without the resolve step, a symlink under HOME pointing outside HOME
would pass the prefix check but the tool would act on the real target
-- a symlink-escape.
move_to_trash also tightens the trash-bin construction (treats empty
file extensions correctly) and switches the "critical directories"
list to truename-resolved canonical forms so a symlinked ~/.config
can't be trashed via an aliased path.
update_text_file fixes an off-by-one in the line-count derivation
when the source content is empty.
Each source change pairs with tests in tests/test-gptel-tools-*.el
and tests/test-update-text-file.el covering the realpath escape
paths, the empty-extension trash case, and the empty-content line-
count edge. Combined coverage is now 100% across all ten gptel-tools
source files: 516 / 516 executable lines, 217 tests.
Diffstat (limited to 'tests/test-update-text-file.el')
| -rw-r--r-- | tests/test-update-text-file.el | 123 |
1 files changed, 122 insertions, 1 deletions
diff --git a/tests/test-update-text-file.el b/tests/test-update-text-file.el index d689274a..fc4f8c36 100644 --- a/tests/test-update-text-file.el +++ b/tests/test-update-text-file.el @@ -148,6 +148,25 @@ (should (equal (cj/update-text-file--insert-at-line "a\nb" 2 "X") "a\nX\nb"))) +(ert-deftest test-update-text-file-insert-at-line-boundary-text-with-trailing-newline () + "Boundary: inserted text that ends in newline is not double-terminated." + (should (equal (cj/update-text-file--insert-at-line "a\nb\n" 2 "X\n") + "a\nX\nb\n"))) + +(ert-deftest test-update-text-file-insert-at-line-boundary-multiline-text () + "Boundary: multi-line inserted text is inserted as a block." + (should (equal (cj/update-text-file--insert-at-line "a\nb\n" 2 "X\nY") + "a\nX\nY\nb\n"))) + +(ert-deftest test-update-text-file-insert-at-line-boundary-empty-file-line-1 () + "Boundary: inserting at line 1 in an empty file works." + (should (equal (cj/update-text-file--insert-at-line "" 1 "X") + "X\n"))) + +(ert-deftest test-update-text-file-insert-at-line-error-empty-file-line-2 () + "Error: line 2 is out of range for an empty file." + (should-error (cj/update-text-file--insert-at-line "" 2 "X"))) + (ert-deftest test-update-text-file-insert-at-line-error-out-of-range () "Error: line number beyond file length signals." (should-error (cj/update-text-file--insert-at-line "a\nb\n" 5 "X"))) @@ -190,6 +209,15 @@ (should (equal (cj/update-text-file--delete-lines "keep\ndrop" "drop") "keep"))) +(ert-deftest test-update-text-file-delete-lines-boundary-empty-file () + "Boundary: deleting from an empty file returns the empty string." + (should (equal (cj/update-text-file--delete-lines "" "anything") ""))) + +(ert-deftest test-update-text-file-delete-lines-boundary-backslash-literal () + "Boundary: backslashes in the pattern are literal." + (should (equal (cj/update-text-file--delete-lines "keep\npath\\name\n" "\\") + "keep\n"))) + (ert-deftest test-update-text-file-delete-lines-error-empty-pattern () "Error: empty pattern signals." (should-error (cj/update-text-file--delete-lines "a\nb\n" ""))) @@ -253,6 +281,61 @@ "Error: a directory signals." (should-error (cj/update-text-file--validate-path "~"))) +(ert-deftest test-update-text-file-validate-path-error-unreadable () + "Error: an unreadable file signals." + (test-update-text-file--in-home + "unreadable" "secret\n" + (lambda (path) + (cl-letf (((symbol-function 'file-readable-p) (lambda (_) nil))) + (should-error (cj/update-text-file--validate-path path)))))) + +(ert-deftest test-update-text-file-validate-path-error-unwritable () + "Error: an unwritable file signals." + (test-update-text-file--in-home + "unwritable" "locked\n" + (lambda (path) + (cl-letf (((symbol-function 'file-writable-p) (lambda (_) nil))) + (should-error (cj/update-text-file--validate-path path)))))) + +(ert-deftest test-update-text-file-validate-path-boundary-relative-home-path () + "Boundary: a relative path resolves under HOME." + (test-update-text-file--in-home + "relative" "ok\n" + (lambda (path) + (let ((relative (file-relative-name path (expand-file-name "~")))) + (should (equal (cj/update-text-file--validate-path relative) + (file-truename path))))))) + +(ert-deftest test-update-text-file-validate-path-boundary-symlink-inside-home () + "Boundary: a symlink inside HOME resolving inside HOME is accepted." + (test-update-text-file--in-home + "symlink-target" "ok\n" + (lambda (target) + (let ((link (expand-file-name + (format ".test-update-text-file-link-%s.tmp" + (format-time-string "%s%N")) + "~"))) + (unwind-protect + (progn + (make-symbolic-link target link t) + (should (equal (cj/update-text-file--validate-path link) + (file-truename target)))) + (when (file-symlink-p link) (delete-file link))))))) + +(ert-deftest test-update-text-file-validate-path-error-symlink-outside-home () + "Error: a symlink inside HOME pointing outside HOME is rejected." + (let ((outside (make-temp-file "test-update-text-file-outside-")) + (link (expand-file-name + (format ".test-update-text-file-outside-link-%s.tmp" + (format-time-string "%s%N")) + "~"))) + (unwind-protect + (progn + (make-symbolic-link outside link t) + (should-error (cj/update-text-file--validate-path link))) + (when (file-exists-p outside) (delete-file outside)) + (when (file-symlink-p link) (delete-file link))))) + ;; ----------------------------------------------------- backup-name (ert-deftest test-update-text-file-backup-name-shape () @@ -291,7 +374,11 @@ Backups (path-TS.bak) are cleaned up after FN returns." (with-temp-buffer (insert-file-contents path) (should (equal (buffer-string) "GAMMA bravo GAMMA\n"))) - (should (file-expand-wildcards (concat path "-*.bak"))))))) + (let ((backup (car (file-expand-wildcards (concat path "-*.bak"))))) + (should backup) + (with-temp-buffer + (insert-file-contents backup) + (should (equal (buffer-string) "alpha bravo alpha\n")))))))) (ert-deftest test-update-text-file-run-no-change-no-backup () "Wrapper: no-op operation leaves the file untouched and creates no backup." @@ -335,6 +422,40 @@ Backups (path-TS.bak) are cleaned up after FN returns." (insert-file-contents path) (should (equal (buffer-string) "keep1\nkeep2\n")))))) +(ert-deftest test-update-text-file-run-error-transform-leaves-file-unchanged () + "Wrapper: transform errors create no backup and leave the file unchanged." + (test-update-text-file--in-home + "transform-error" "abc\n" + (lambda (path) + (should-error (cj/update-text-file--run path "replace" "" "x" nil)) + (with-temp-buffer + (insert-file-contents path) + (should (equal (buffer-string) "abc\n"))) + (should-not (file-expand-wildcards (concat path "-*.bak")))))) + +(ert-deftest test-update-text-file-run-error-unknown-operation-leaves-file-unchanged () + "Wrapper: unknown operations create no backup and leave the file unchanged." + (test-update-text-file--in-home + "unknown-operation" "abc\n" + (lambda (path) + (should-error (cj/update-text-file--run path "frobnicate" "x" nil nil)) + (with-temp-buffer + (insert-file-contents path) + (should (equal (buffer-string) "abc\n"))) + (should-not (file-expand-wildcards (concat path "-*.bak")))))) + +(ert-deftest test-update-text-file-run-error-too-large-leaves-file-unchanged () + "Wrapper: the size guard errors before backup/write." + (test-update-text-file--in-home + "too-large" "abcdef\n" + (lambda (path) + (let ((cj/update-text-file--size-limit 3)) + (should-error (cj/update-text-file--run path "append" "x" nil nil))) + (with-temp-buffer + (insert-file-contents path) + (should (equal (buffer-string) "abcdef\n"))) + (should-not (file-expand-wildcards (concat path "-*.bak")))))) + (ert-deftest test-update-text-file-run-error-missing-file () "Wrapper: missing file signals." (let ((path (expand-file-name |
