aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-06 11:23:29 -0500
committerCraig Jennings <c@cjennings.net>2026-06-06 11:23:29 -0500
commit7757f6909bfcc20211e8ae1f4ad364082ca924f5 (patch)
treebc9f5bb14bcf1b07f954515dd8e7bfda27045f2d
parent3d8778ba12cbbe2b8f6d5512d4b4a8f13a9c55ac (diff)
downloadduet-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.el22
-rw-r--r--tests/test-duet-backend.el26
-rw-r--r--tests/test-duet-transfer.el8
3 files changed, 53 insertions, 3 deletions
diff --git a/duet.el b/duet.el
index e8ae8c4..28d45f2 100644
--- a/duet.el
+++ b/duet.el
@@ -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 ()