diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-06 10:50:10 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-06 10:50:10 -0500 |
| commit | fdc5f550d35d97272e3e5cac2f46ca7f892dda09 (patch) | |
| tree | b501eaa84d4ed69e1833d71b4b20468f50c00926 | |
| parent | 04f9eb281529965c4aff9ca9176b549fac4ae30f (diff) | |
| download | duet-fdc5f550d35d97272e3e5cac2f46ca7f892dda09.tar.gz duet-fdc5f550d35d97272e3e5cac2f46ca7f892dda09.zip | |
feat: add the transport-backend registry and contract checks
This is the seam every transport plugs into (Phase 2 in the design spec). A duet-backend struct carries a scorer, a command builder, capability flags, and contract metadata; duet-register-backend keeps a registry where re-registering a name replaces the prior backend, which is how a user or plugin overrides a built-in. duet--select-backend asks every backend to score an endpoint pair and picks the lowest cost, breaking ties toward the most recently registered.
The failure-normalizer interface turns a raw process failure into a class, a cause, evidence, a safety outcome, and next actions. Backends supply a pattern table via duet-define-cli-failure-patterns; anything unmatched falls back to a minimal normalizer that covers launch failure, missing executable, stall, signal, and unknown exit. This is what will let DUET explain a failure instead of dumping stderr.
Secrets are redacted before they reach a log: a pattern keeps its group-1 field label and strips the value, and a backend with no secret surface declares :none rather than an empty pattern list, so a forgotten declaration (nil) stays distinguishable from a deliberate one.
The tiered contract checks (minimum, publishable, capability) return a list of violations a backend author asserts is empty in one ERT test. The built-in rsync and TRAMP backends register through this same API in Phase 3, once their command builders land; here the machinery is exercised by fake backends.
| -rw-r--r-- | duet.el | 252 | ||||
| -rw-r--r-- | tests/test-duet-backend.el | 231 |
2 files changed, 482 insertions, 1 deletions
@@ -34,6 +34,7 @@ ;;; Code: (require 'tramp) +(require 'cl-lib) (defgroup duet nil "Dual-pane file commander over dirvish/dired." @@ -80,11 +81,260 @@ than signaling. Validating raw TRAMP input is the connection reader's job." :localname (expand-file-name path) :hop nil))) +;;; Transport-backend registry + +(cl-defstruct (duet-backend (:constructor duet-backend-create) (:copier nil)) + "A transport backend: a scorer, a command builder, and contract metadata. + +Slots: + + name unique symbol; re-registering a name replaces the backend + handles (lambda (SRC DST) ...) -> numeric cost (lower preferred) or + nil when the backend cannot handle the endpoint pair + command (lambda (SRC DST OPTS) ...) -> a process-spec plist (its + :argv is an argument list, never a shell string) + capabilities plist of advertised flags: :async :resume :bidirectional + :progress + temp-pattern regexp naming the backend's temp/partial files, or nil + cleanup cleanup semantics: `:verifiable', `:best-effort', or `:none' + redaction list of regexps whose matches are stripped from logs (each + pattern's capture group 1 is the field label kept verbatim), + or the symbol `:none' for a backend with no secret surface + normalizer failure-normalizer function, or nil to use the minimal one" + name handles command capabilities temp-pattern cleanup redaction normalizer) + +(defvar duet--backend-registry nil + "List of registered `duet-backend' structs, most recently registered first.") + +(defun duet-register-backend (backend) + "Register BACKEND, replacing any existing backend of the same name. +Re-registering a name is how a user or plugin overrides a built-in. Return +BACKEND." + (setq duet--backend-registry + (cons backend + (cl-remove (duet-backend-name backend) + duet--backend-registry + :key #'duet-backend-name))) + backend) + +(defun duet-backends () + "Return the registered backends, most recently registered first." + duet--backend-registry) + +(defun duet-backend-by-name (name) + "Return the registered backend named NAME, or nil." + (cl-find name duet--backend-registry :key #'duet-backend-name)) + +(defun duet--select-backend (src dst) + "Return the registered backend best suited to transfer SRC to DST. +Each backend's scorer is called with the classified endpoints SRC and DST and +returns a numeric cost (lower preferred) or nil when it cannot handle the +pair. The lowest-cost backend wins; ties resolve to the most recently +registered one. Return nil when no backend handles the pair." + (let ((best nil) (best-score nil)) + (dolist (b duet--backend-registry best) + (let ((score (funcall (duet-backend-handles b) src dst))) + (when (and (numberp score) + (or (null best-score) (< score best-score))) + (setq best b + best-score score)))))) + +;;; Secret redaction + +(defconst duet--redaction-marker "<redacted>" + "Replacement text substituted for matched secrets in logs and bug reports.") + +(defun duet--redact (text patterns) + "Return TEXT with every regexp in PATTERNS redacted. +Each pattern keeps its capture group 1 (the field label) and replaces the +rest of the match with the redaction marker, so a log can show that a secret +field was present without leaking its value. A pattern with no group 1 +redacts the whole match. TEXT is returned unchanged when PATTERNS is nil or +the symbol `:none' (a backend declaring it has no secret surface)." + (if (or (null patterns) (eq patterns :none)) + text + (let ((out text)) + (dolist (re patterns out) + (setq out (replace-regexp-in-string + re + (lambda (match) + (save-match-data + (if (and (string-match re match) (match-beginning 1)) + (concat (match-string 1 match) duet--redaction-marker) + duet--redaction-marker))) + out t t)))))) + +;;; Failure normalization + +(defun duet--failure-evidence (context) + "Return the evidence lines from a failure CONTEXT as a list of strings." + (delq nil (list (plist-get context :launch-error) + (plist-get context :stderr)))) + +(defun duet-backend-minimal-failure-normalizer (context) + "Map a generic failure CONTEXT into a normalized explanation plist. +CONTEXT is a plist with any of :launch-error, :executable-missing, :timeout, +:signal, :exit, and :stderr. The result carries :class, :cause, :evidence, +:safety, and :next-actions. Every backend gets this for free; a backend's +own normalizer refines the classification for failures it recognizes." + (let ((exit (plist-get context :exit)) + (signal (plist-get context :signal)) + (evidence (duet--failure-evidence context))) + (cond + ((plist-get context :executable-missing) + (list :class 'missing-executable + :cause "The backend program was not found on PATH." + :evidence evidence :safety "Source unchanged; nothing ran." + :next-actions '(run-doctor))) + ((plist-get context :launch-error) + (list :class 'launch-failure + :cause "DUET could not launch the backend process." + :evidence evidence :safety "Source unchanged; nothing ran." + :next-actions '(run-doctor))) + ((plist-get context :timeout) + (list :class 'stalled + :cause "The transfer produced no output before the stall timeout." + :evidence evidence + :safety "Source unchanged; the destination may hold a partial." + :next-actions '(retry cancel))) + (signal + (list :class 'cancelled + :cause (format "The backend was terminated by signal %s." signal) + :evidence evidence + :safety "Source unchanged; the destination may hold a partial." + :next-actions '(retry))) + ((and (integerp exit) (/= exit 0)) + (list :class 'backend-unknown-failure + :cause (format "The backend exited with status %d." exit) + :evidence evidence :safety :generic + :next-actions '(retry run-doctor))) + (t + (list :class 'backend-unknown-failure + :cause "The backend failed for an unrecognized reason." + :evidence evidence :safety :generic + :next-actions '(run-doctor)))))) + +(defun duet--failure-pattern-match-p (match context) + "Return non-nil when MATCH applies to failure CONTEXT. +MATCH is a regexp tested against the context's :stderr, or a predicate +function called with the whole context." + (cond + ((functionp match) (funcall match context)) + ((stringp match) + (let ((stderr (plist-get context :stderr))) + (and stderr (string-match-p match stderr)))) + (t nil))) + +(defun duet--apply-failure-pattern (pattern context) + "Return a normalized failure for PATTERN if it matches CONTEXT, else nil." + (when (duet--failure-pattern-match-p (plist-get pattern :match) context) + (list :class (plist-get pattern :class) + :cause (plist-get pattern :cause) + :evidence (duet--failure-evidence context) + :safety (or (plist-get pattern :safety) :generic) + :next-actions (plist-get pattern :next-actions)))) + +(defun duet-define-cli-failure-patterns (patterns) + "Return a failure-normalizer function built from PATTERNS. +Each entry is a plist with :match (a regexp over stderr or a predicate over +the context), :class, :cause, :next-actions, and an optional :safety. The +returned function tries each pattern in order and falls back to +`duet-backend-minimal-failure-normalizer' when none match." + (lambda (context) + (or (cl-some (lambda (p) (duet--apply-failure-pattern p context)) patterns) + (duet-backend-minimal-failure-normalizer context)))) + +(defun duet--normalize-failure (backend context) + "Normalize a failure CONTEXT for BACKEND into an explanation plist. +Consult BACKEND's own normalizer when it has one, otherwise the minimal +normalizer." + (funcall (or (duet-backend-normalizer backend) + #'duet-backend-minimal-failure-normalizer) + context)) + +;;; Backend contract checks (tiered) + +(defun duet--check-name (backend) + "Return a minimum-tier name violation for BACKEND, or nil." + (unless (and (duet-backend-name backend) + (symbolp (duet-backend-name backend))) + "name must be a non-nil symbol")) + +(defun duet--check-handles (backend src dst) + "Return a minimum-tier scorer violation for BACKEND on SRC/DST, or nil." + (if (not (functionp (duet-backend-handles backend))) + "handles must be a function" + (let ((score (funcall (duet-backend-handles backend) src dst))) + (unless (or (null score) (numberp score)) + "handles must return a number or nil")))) + +(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"))))) + +(defun duet--check-redaction (backend) + "Return a minimum-tier redaction violation for BACKEND, or nil. +A backend declares either a non-empty list of regexps or `:none' (no secret +surface). An unset slot (nil) is a forgotten declaration and is flagged." + (let ((r (duet-backend-redaction backend))) + (unless (or (eq r :none) (and (listp r) r)) + "redaction metadata must be declared (a non-empty list of regexps, or :none)"))) + +(defun duet--check-normalizer (backend) + "Return a minimum-tier failure-explainer violation for BACKEND, or nil." + (let ((n (duet--normalize-failure backend '(:exit 1 :stderr "sample")))) + (unless (and (plist-member n :class) + (plist-member n :cause) + (plist-member n :next-actions)) + "failure normalizer must return a class/cause/next-actions plist"))) + +(defun duet-backend-check-minimum (backend &optional src dst opts) + "Return a list of minimum-tier contract violations for BACKEND, or nil. +SRC, DST, and OPTS are a sample endpoint pair and options the backend should +handle; they default to a local-to-local pair. An empty result means +BACKEND is registrable: it names itself, scores and builds an argv command +for the sample (never a shell string), declares redaction metadata, and +produces a well-formed failure explanation. An author wraps this in an ERT +test: (should-not (duet-backend-check-minimum my-backend))." + (let ((src (or src '(:locality local :localname "/tmp/a"))) + (dst (or dst '(:locality local :localname "/tmp/b")))) + (delq nil (list (duet--check-name backend) + (duet--check-handles backend src dst) + (duet--check-command backend src dst opts) + (duet--check-redaction backend) + (duet--check-normalizer backend))))) + +(defun duet-backend-check-publishable (backend &optional src dst opts) + "Return minimum-tier violations for BACKEND plus publishable-tier ones. +A publishable backend additionally declares cleanup semantics and carries its +own failure normalizer, so DUET can recommend it safely." + (append (duet-backend-check-minimum backend src dst opts) + (delq nil + (list (unless (duet-backend-cleanup backend) + "cleanup semantics must be declared (:verifiable/:best-effort/:none)") + (unless (duet-backend-normalizer backend) + "a backend-specific failure normalizer is required to publish"))))) + +(defun duet-backend-check-capability (backend capability &optional src dst opts) + "Return publishable-tier violations for BACKEND plus a CAPABILITY assertion. +CAPABILITY (e.g. :resume) must be declared in the backend's `capabilities' +before DUET will trust it." + (append (duet-backend-check-publishable backend src dst opts) + (delq nil + (list (unless (plist-get (duet-backend-capabilities backend) capability) + (format "capability %s is asserted but not declared" capability)))))) + ;;;###autoload (defun duet () "Launch the DUET dual-pane file commander." (interactive) - (user-error "DUET is not yet implemented — see the design document")) + (user-error "DUET is not yet implemented; see the design document")) (provide 'duet) ;;; duet.el ends here diff --git a/tests/test-duet-backend.el b/tests/test-duet-backend.el new file mode 100644 index 0000000..b1d9c34 --- /dev/null +++ b/tests/test-duet-backend.el @@ -0,0 +1,231 @@ +;;; test-duet-backend.el --- Tests for the duet backend registry -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for the transport-backend registry, scorer, redaction helper, +;; failure-normalizer interface, and the tiered contract-check functions. +;; The machinery is exercised with fake backends defined here; the concrete +;; rsync/TRAMP backends register in Phase 3 once their command builders exist. +;; +;; Each test rebinds `duet--backend-registry' to nil so registrations never +;; leak between tests (no shared mutable state). + +;;; Code: + +(require 'test-bootstrap (expand-file-name "test-bootstrap.el")) + +(defun test-duet-backend--fake (name score &rest overrides) + "Build a well-formed fake backend named NAME whose scorer returns SCORE. +OVERRIDES is a plist that wins over the conservative defaults. It is spliced +ahead of the defaults because cl-defstruct constructors take the leftmost +value for a duplicated keyword." + (apply #'duet-backend-create + (append + overrides + (list :name name + :handles (lambda (_src _dst) score) + :command (lambda (_src _dst _opts) + (list :argv (list "echo" (symbol-name name)) + :default-directory "/")) + :capabilities '(:async t) + :redaction '("\\(password=\\)[^ \n]+"))))) + +;;; Registry — add, find, replace, order + +(ert-deftest test-duet-backend-register-and-find () + "A registered backend is retrievable by name." + (let ((duet--backend-registry nil)) + (duet-register-backend (test-duet-backend--fake 'alpha 10)) + (should (duet-backend-p (duet-backend-by-name 'alpha))) + (should (= 1 (length (duet-backends)))))) + +(ert-deftest test-duet-backend-register-replaces-same-name () + "Re-registering a name replaces the prior backend rather than duplicating." + (let ((duet--backend-registry nil)) + (duet-register-backend (test-duet-backend--fake 'alpha 10)) + (duet-register-backend (test-duet-backend--fake 'alpha 99)) + (should (= 1 (length (duet-backends)))) + (should (= 99 (funcall (duet-backend-handles (duet-backend-by-name 'alpha)) + nil nil))))) + +(ert-deftest test-duet-backend-register-newest-first () + "The most recently registered backend sorts ahead of older ones." + (let ((duet--backend-registry nil)) + (duet-register-backend (test-duet-backend--fake 'alpha 10)) + (duet-register-backend (test-duet-backend--fake 'beta 20)) + (should (equal '(beta alpha) + (mapcar #'duet-backend-name (duet-backends)))))) + +;;; Scoring — lowest wins, nil skips, ties break by recency + +(ert-deftest test-duet-backend-select-lowest-score-wins () + "The backend with the lowest score handles the pair." + (let ((duet--backend-registry nil)) + (duet-register-backend (test-duet-backend--fake 'expensive 50)) + (duet-register-backend (test-duet-backend--fake 'cheap 5)) + (should (eq 'cheap (duet-backend-name (duet--select-backend nil nil)))))) + +(ert-deftest test-duet-backend-select-skips-nil-scorers () + "A backend whose scorer returns nil cannot handle the pair and is skipped." + (let ((duet--backend-registry nil)) + (duet-register-backend (test-duet-backend--fake 'declines nil)) + (duet-register-backend (test-duet-backend--fake 'handles 30)) + (should (eq 'handles (duet-backend-name (duet--select-backend nil nil)))))) + +(ert-deftest test-duet-backend-select-none-handles-returns-nil () + "When no backend scores the pair, selection returns nil." + (let ((duet--backend-registry nil)) + (duet-register-backend (test-duet-backend--fake 'a nil)) + (duet-register-backend (test-duet-backend--fake 'b nil)) + (should (null (duet--select-backend nil nil))))) + +(ert-deftest test-duet-backend-select-tie-breaks-by-recency () + "Equal scores resolve to the most recently registered backend." + (let ((duet--backend-registry nil)) + (duet-register-backend (test-duet-backend--fake 'older 10)) + (duet-register-backend (test-duet-backend--fake 'newer 10)) + (should (eq 'newer (duet-backend-name (duet--select-backend nil nil)))))) + +;;; Redaction + +(ert-deftest test-duet-redact-replaces-pattern-matches () + "A pattern's secret is redacted while its group-1 label is kept." + (let ((out (duet--redact "ssh password=hunter2 host=example" + '("\\(password=\\)[^ \n]+")))) + (should (string-match-p "password=<redacted>" out)) + (should-not (string-match-p "hunter2" out)) + (should (string-match-p "host=example" out)))) + +(ert-deftest test-duet-redact-applies-every-pattern () + "Multiple patterns each redact independently." + (let ((out (duet--redact "token=abc secret=def" + '("\\(token=\\)[^ \n]+" "\\(secret=\\)[^ \n]+")))) + (should-not (string-match-p "abc" out)) + (should-not (string-match-p "def" out)))) + +(ert-deftest test-duet-redact-no-patterns-returns-input () + "With no patterns the text is returned unchanged." + (should (equal "nothing here" (duet--redact "nothing here" nil)))) + +(ert-deftest test-duet-redact-none-returns-input () + "A backend declaring :none (no secret surface) redacts nothing." + (should (equal "user@host:/path" (duet--redact "user@host:/path" :none)))) + +;;; Failure normalizer — minimal mapping and shape + +(ert-deftest test-duet-normalize-failure-shape-has-all-keys () + "A normalized failure carries class, cause, evidence, safety, next-actions." + (let ((n (duet--normalize-failure + (test-duet-backend--fake 'x 1) + '(:exit 1 :stderr "boom")))) + (should (plist-member n :class)) + (should (plist-member n :cause)) + (should (plist-member n :evidence)) + (should (plist-member n :safety)) + (should (plist-member n :next-actions)))) + +(ert-deftest test-duet-normalize-failure-launch-error () + "A launch error classifies as launch-failure." + (let ((n (duet--normalize-failure + (test-duet-backend--fake 'x 1) + '(:launch-error "No such file or directory")))) + (should (eq 'launch-failure (plist-get n :class))))) + +(ert-deftest test-duet-normalize-failure-unknown-nonzero-exit () + "An unrecognized nonzero exit falls back to backend-unknown-failure." + (let ((n (duet--normalize-failure + (test-duet-backend--fake 'x 1) + '(:exit 42 :stderr "weird")))) + (should (eq 'backend-unknown-failure (plist-get n :class))))) + +;;; Failure-pattern mechanism + +(ert-deftest test-duet-failure-patterns-match-by-stderr-regexp () + "A pattern whose :match regexp hits stderr supplies its class and cause." + (let* ((normalizer + (duet-define-cli-failure-patterns + '((:match "Permission denied" :class permission-denied + :cause "The destination rejected the write." + :next-actions (fix-permissions) + :safety "Source unchanged.")))) + (n (funcall normalizer '(:exit 23 :stderr "rsync: Permission denied (13)")))) + (should (eq 'permission-denied (plist-get n :class))) + (should (equal '(fix-permissions) (plist-get n :next-actions))))) + +(ert-deftest test-duet-failure-patterns-fall-back-to-minimal () + "When no pattern matches, the normalizer falls back to the minimal mapping." + (let* ((normalizer + (duet-define-cli-failure-patterns + '((:match "Permission denied" :class permission-denied + :cause "..." :next-actions (fix-permissions))))) + (n (funcall normalizer '(:exit 99 :stderr "totally different")))) + (should (eq 'backend-unknown-failure (plist-get n :class))))) + +(ert-deftest test-duet-normalize-failure-uses-backend-normalizer () + "A backend carrying a normalizer has it consulted before the minimal mapping." + (let* ((normalizer + (duet-define-cli-failure-patterns + '((:match "rate" :class rate-limited :cause "Throttled." + :next-actions (retry-later))))) + (backend (test-duet-backend--fake 'cloud 1 :normalizer normalizer)) + (n (duet--normalize-failure backend '(:exit 1 :stderr "API rate exceeded")))) + (should (eq 'rate-limited (plist-get n :class))))) + +;;; Contract checks — tiered + +(ert-deftest test-duet-backend-check-minimum-passes-clean-backend () + "A well-formed fake backend reports no minimum-tier violations." + (should (null (duet-backend-check-minimum (test-duet-backend--fake 'good 10))))) + +(ert-deftest test-duet-backend-check-minimum-flags-missing-redaction () + "A backend that declares no redaction metadata fails the minimum tier." + (let ((b (test-duet-backend--fake 'noredact 10 :redaction nil))) + (should (duet-backend-check-minimum b)))) + +(ert-deftest test-duet-backend-check-minimum-accepts-none-redaction () + "A backend with no secret surface declares :none and passes the minimum tier." + (let ((b (test-duet-backend--fake 'localonly 10 :redaction :none))) + (should (null (duet-backend-check-minimum b))))) + +(ert-deftest test-duet-backend-check-minimum-flags-shell-string-command () + "A command builder returning a shell string instead of an argv list fails." + (let ((b (test-duet-backend--fake 'shellish 10 + :command (lambda (_s _d _o) + (list :shell-command "rm -rf /"))))) + (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 + :normalizer (duet-define-cli-failure-patterns nil)))) + ;; cleanup slot is unset, so publishable must flag it even though minimum passes. + (should (null (duet-backend-check-minimum b))) + (should (duet-backend-check-publishable b)))) + +(ert-deftest test-duet-backend-check-publishable-includes-minimum () + "Publishable problems are a superset of the minimum-tier problems." + (let* ((b (test-duet-backend--fake 'broken 10 :redaction nil)) + (min (duet-backend-check-minimum b)) + (pub (duet-backend-check-publishable b))) + (should min) + (should (cl-every (lambda (p) (member p pub)) min)))) + +(provide 'test-duet-backend) +;;; test-duet-backend.el ends here |
