diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-06 11:23:29 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-06 11:23:29 -0500 |
| commit | 7757f6909bfcc20211e8ae1f4ad364082ca924f5 (patch) | |
| tree | bc9f5bb14bcf1b07f954515dd8e7bfda27045f2d | |
| parent | 3d8778ba12cbbe2b8f6d5512d4b4a8f13a9c55ac (diff) | |
| download | duet-7757f6909bfcc20211e8ae1f4ad364082ca924f5.tar.gz duet-7757f6909bfcc20211e8ae1f4ad364082ca924f5.zip | |
fix: make the in-process execution mode explicit in specs and contract checks
Two coupled holes surfaced in the Phase 0-3 review. duet--transfer-spec copied only :argv, :default-directory, and :process-environment out of a backend's command result, dropping the :tramp marker, so a TRAMP-routed spec arrived as :argv nil with nothing telling the executor to copy in process. And duet--check-command leaned on listp, where nil is a list in Elisp, so a command builder returning nil or a bare :argv nil passed the minimum tier.
Both turn on the same idea, so they share a fix. duet--command-spec-executable-p defines a runnable spec: a non-empty plist with either a non-empty :argv of strings (a CLI backend) or a declared in-process mode such as :tramp. The contract checker rejects anything else, and transfer-spec now carries :tramp through, so the TRAMP fallback has a positive execution signal rather than an ambiguous nil argv.
The legitimate TRAMP backend keeps passing because it declares its mode. A broken backend that forgets argv no longer slips through.
| -rw-r--r-- | duet.el | 22 | ||||
| -rw-r--r-- | tests/test-duet-backend.el | 26 | ||||
| -rw-r--r-- | tests/test-duet-transfer.el | 8 |
3 files changed, 53 insertions, 3 deletions
@@ -268,15 +268,30 @@ normalizer." (unless (or (null score) (numberp score)) "handles must return a number or nil")))) +(defun duet--command-spec-executable-p (spec) + "Return non-nil when process SPEC has a recognized, runnable execution shape. +A SPEC is runnable when it is a non-empty plist carrying either a non-empty +:argv whose elements are all strings (a CLI backend) or an explicit +in-process mode such as :tramp (a backend that copies in process). A nil +spec, a bare nil argv, or a shell string is not runnable." + (and (listp spec) spec + (not (plist-get spec :shell-command)) + (or (plist-get spec :tramp) + (let ((argv (plist-get spec :argv))) + (and (consp argv) (cl-every #'stringp argv)))))) + (defun duet--check-command (backend src dst opts) "Return a minimum-tier command violation for BACKEND on SRC/DST/OPTS, or nil." (if (not (functionp (duet-backend-command backend))) "command must be a function" (let ((spec (funcall (duet-backend-command backend) src dst opts))) (cond - ((not (listp spec)) "command must return a process-spec plist") - ((plist-get spec :shell-command) "command must not build a shell string; use :argv") - ((not (listp (plist-get spec :argv))) "command :argv must be an argument list"))))) + ((not (and (listp spec) spec)) + "command must return a non-empty process-spec plist") + ((plist-get spec :shell-command) + "command must not build a shell string; use :argv") + ((not (duet--command-spec-executable-p spec)) + "command must return a runnable spec: a non-empty :argv of strings, or a declared in-process mode such as :tramp"))))) (defun duet--check-redaction (backend) "Return a minimum-tier redaction violation for BACKEND, or nil. @@ -494,6 +509,7 @@ backend's command builder. Return nil when no backend handles the pair." :backend (duet-backend-name backend) :route (duet--transfer-route src dst opts) :argv (plist-get cmd :argv) + :tramp (plist-get cmd :tramp) :default-directory (plist-get cmd :default-directory) :process-environment (plist-get cmd :process-environment) :async (if (plist-member opts :async) (plist-get opts :async) t)))))) diff --git a/tests/test-duet-backend.el b/tests/test-duet-backend.el index b1d9c34..f0a999c 100644 --- a/tests/test-duet-backend.el +++ b/tests/test-duet-backend.el @@ -211,6 +211,32 @@ value for a duplicated keyword." (list :shell-command "rm -rf /"))))) (should (duet-backend-check-minimum b)))) +(ert-deftest test-duet-backend-check-minimum-flags-nil-command-spec () + "A command builder returning nil fails the minimum tier (nil is a list)." + (let ((b (test-duet-backend--fake 'nilcmd 10 :command (lambda (_s _d _o) nil)))) + (should (duet-backend-check-minimum b)))) + +(ert-deftest test-duet-backend-check-minimum-flags-empty-argv-cli () + "A CLI backend with nil argv and no declared in-process mode fails." + (let ((b (test-duet-backend--fake + 'noargv 10 + :command (lambda (_s _d _o) (list :argv nil :default-directory "/"))))) + (should (duet-backend-check-minimum b)))) + +(ert-deftest test-duet-backend-check-minimum-accepts-in-process-spec () + "A backend declaring an in-process mode (:tramp) passes with a nil argv." + (let ((b (test-duet-backend--fake + 'inproc 10 + :command (lambda (_s _d _o) (list :argv nil :tramp t))))) + (should (null (duet-backend-check-minimum b))))) + +(ert-deftest test-duet-backend-check-minimum-flags-non-string-argv () + "An argv carrying non-string elements is not a runnable CLI command." + (let ((b (test-duet-backend--fake + 'bad 10 + :command (lambda (_s _d _o) (list :argv '("rsync" 42)))))) + (should (duet-backend-check-minimum b)))) + (ert-deftest test-duet-backend-check-publishable-flags-missing-cleanup () "The publishable tier additionally requires declared cleanup semantics." (let ((b (test-duet-backend--fake 'pub 10 diff --git a/tests/test-duet-transfer.el b/tests/test-duet-transfer.el index a5d0429..ebe5a2c 100644 --- a/tests/test-duet-transfer.el +++ b/tests/test-duet-transfer.el @@ -120,6 +120,14 @@ (should (eq 'rsync (plist-get spec :backend))) (should (eq :local-remote (plist-get spec :route)))))) +(ert-deftest test-duet-transfer-spec-preserves-tramp-marker () + "A spec routed through TRAMP carries the in-process marker, not just nil argv." + (test-duet-transfer--with-builtins + (let ((spec (duet--transfer-spec '("/tmp/a/file.txt") "/ftp:host:/b" nil))) + (should (eq 'tramp (plist-get spec :backend))) + (should (eq t (plist-get spec :tramp))) + (should (null (plist-get spec :argv)))))) + ;;; Conflict planning — pure, prompt-free (ert-deftest test-duet-plan-conflicts-no-collisions-all-copy () |
