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-gptel-tools-list-directory-files.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-gptel-tools-list-directory-files.el')
| -rw-r--r-- | tests/test-gptel-tools-list-directory-files.el | 95 |
1 files changed, 95 insertions, 0 deletions
diff --git a/tests/test-gptel-tools-list-directory-files.el b/tests/test-gptel-tools-list-directory-files.el index a91a7e79..9588ce8b 100644 --- a/tests/test-gptel-tools-list-directory-files.el +++ b/tests/test-gptel-tools-list-directory-files.el @@ -72,6 +72,14 @@ (expand-file-name "sub" root)))) (should (plist-get info :is-directory)))))) +(ert-deftest test-gptel-tools-list-get-file-info-error () + "Error: metadata failures are returned as failed info plists." + (cl-letf (((symbol-function 'file-attributes) + (lambda (&rest _args) (error "stat failed")))) + (let ((info (list-directory-files--get-file-info "/tmp/nope"))) + (should-not (plist-get info :success)) + (should (string-match-p "stat failed" (plist-get info :error)))))) + ;; -------------------------- filter-by-extension (ert-deftest test-gptel-tools-list-filter-by-extension-keeps-match () @@ -96,6 +104,18 @@ "No extension produces a nil filter (i.e. no filtering)." (should-not (list-directory-files--filter-by-extension nil))) +(ert-deftest test-gptel-tools-list-filter-by-extension-case-insensitive () + "Boundary: extension filtering is case-insensitive." + (let* ((filter (list-directory-files--filter-by-extension "txt")) + (info '(:success t :path "/x/FOO.TXT" :is-directory nil))) + (should (funcall filter info)))) + +(ert-deftest test-gptel-tools-list-filter-by-extension-drops-failed-file-info () + "Boundary: failed file info entries do not pass file extension filters." + (let* ((filter (list-directory-files--filter-by-extension "txt")) + (info '(:success nil :path "/x/foo.txt" :is-directory nil))) + (should-not (funcall filter info)))) + ;; -------------------------- format-file-entry (ert-deftest test-gptel-tools-list-format-file-entry-shape () @@ -133,6 +153,49 @@ (paths (mapcar (lambda (i) (plist-get i :path)) files))) (should (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths)))))) +(ert-deftest test-gptel-tools-list-list-directory-max-depth () + "Boundary: max-depth limits recursive traversal." + (test-gptel-tools-list--with-tree + (lambda (root) + (let* ((result (list-directory-files--list-directory root t nil 0)) + (files (plist-get result :files)) + (paths (mapcar (lambda (i) (plist-get i :path)) files))) + (should-not (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths)))))) + +(ert-deftest test-gptel-tools-list-list-directory-filtered-recursive-keeps-matching-files () + "Normal: recursive extension filter returns matching nested files." + (test-gptel-tools-list--with-tree + (lambda (root) + (let* ((filter (list-directory-files--filter-by-extension "txt")) + (result (list-directory-files--list-directory root t filter)) + (files (plist-get result :files)) + (paths (mapcar (lambda (i) (plist-get i :path)) files))) + (should (cl-some (lambda (p) (string-match-p "/a\\.txt\\'" p)) paths)) + (should (cl-some (lambda (p) (string-match-p "/c\\.txt\\'" p)) paths)) + (should-not (cl-some (lambda (p) (string-match-p "/b\\.org\\'" p)) paths)))))) + +(ert-deftest test-gptel-tools-list-list-directory-records-entry-errors () + "Error: per-entry metadata failures are collected." + (test-gptel-tools-list--with-tree + (lambda (root) + (cl-letf (((symbol-function 'list-directory-files--get-file-info) + (lambda (path) + (if (string-match-p "/a\\.txt\\'" path) + (list :success nil :path path :error "denied") + (let* ((attrs (file-attributes path 'string)) + (dirp (eq t (file-attribute-type attrs)))) + (list :success t + :path path + :size 0 + :last-modified (current-time) + :is-directory dirp + :permissions "-rw-r--r--" + :executable nil)))))) + (let ((errors (plist-get (list-directory-files--list-directory root nil nil) + :errors))) + (should errors) + (should (string-match-p "denied" (car errors)))))))) + (ert-deftest test-gptel-tools-list-list-directory-error-not-a-directory () "Non-directory path returns errors entry." (test-gptel-tools-list--with-tree @@ -142,6 +205,17 @@ (errors (plist-get result :errors))) (should errors))))) +(ert-deftest test-gptel-tools-list-list-directory-error-accessing-directory () + "Error: directory access failures are collected." + (test-gptel-tools-list--with-tree + (lambda (root) + (cl-letf (((symbol-function 'directory-files) + (lambda (&rest _args) (error "cannot list")))) + (let ((errors (plist-get (list-directory-files--list-directory root nil nil) + :errors))) + (should errors) + (should (string-match-p "cannot list" (car errors)))))))) + ;; -------------------------- format-output (ert-deftest test-gptel-tools-list-format-output-has-files-section () @@ -158,5 +232,26 @@ "/nowhere" '(:files nil :errors nil)))) (should (string-match-p "No files found" out)))) +(ert-deftest test-gptel-tools-list-format-output-errors-only () + "Format-output includes errors when no files are present." + (let ((out (list-directory-files--format-output + "/nowhere" '(:files nil :errors ("boom"))))) + (should (string-match-p "Errors encountered" out)) + (should (string-match-p "boom" out)))) + +(ert-deftest test-gptel-tools-list-format-output-files-and-errors () + "Format-output separates file listings and errors." + (let* ((info (list :success t + :path (expand-file-name "foo.txt" "~") + :size 1 + :last-modified (current-time) + :is-directory nil + :permissions "-rw-r--r--" + :executable nil)) + (out (list-directory-files--format-output + "~" (list :files (list info) :errors (list "boom"))))) + (should (string-match-p "Found 1 file" out)) + (should (string-match-p "Errors encountered" out)))) + (provide 'test-gptel-tools-list-directory-files) ;;; test-gptel-tools-list-directory-files.el ends here |
