diff --git a/README.md b/README.md index 58a4f76..34e6507 100644 --- a/README.md +++ b/README.md @@ -248,9 +248,11 @@ DWSHELL_REMOTE_PASSWORD=… dwshell alice@myhost -c "id" - `-c ` — run a command non-interactively and exit with its code. Long commands are fine — they are split across several protocol messages, the - way typing them would be — but the *remote shell* still applies its own limit: - `cmd.exe` on Windows truncates a command line past 8191 characters, so pass a - bigger script with `dwshell put` and run it by path. + way typing them would be — but the *remote shell* still caps how long a single + command line may be, and the cap differs from shell to shell. A remote that + truncates the line is reported as such instead of leaving you waiting on + output that can never arrive; pass a bigger script with `dwshell put` and run + it by path. - `--own` / `--shared` — resolve `` among owned agents / incoming shares only. - `--term ` — TERM to send to a *nix remote (default: your local `$TERM`). - `--no-term` — do not send a TERM to the remote. diff --git a/internal/app/shell/run.go b/internal/app/shell/run.go index 0727809..ead0e47 100644 --- a/internal/app/shell/run.go +++ b/internal/app/shell/run.go @@ -3,6 +3,7 @@ package shell import ( "bytes" "context" + "errors" "fmt" "regexp" "strconv" @@ -15,6 +16,7 @@ import ( var ( reBegin = regexp.MustCompile(`__DWSH_BEGIN__\r?\n`) reRC = regexp.MustCompile(`__DWSH_RC_(\d+)_END__`) + reTrunc = regexp.MustCompile(`__DWSH_TRUNC_(\d+)_END__`) ) // RunResult is the outcome of a non-interactive command. @@ -38,6 +40,30 @@ func wrapCommand(cmd string, os remote.OS) string { return fmt.Sprintf("echo __DWSH_BEGIN__; ( %s ); echo __DWSH_RC_$?_END__\r", cmd) } +// errTruncated reports a command line the remote shell cut short. How long a +// line a remote accepts is the remote shell's own business and differs between +// shells, so dwshell does not try to predict it — it reports the truncation +// when it happens instead. +var errTruncated = errors.New( + "the remote shell truncated the command line, so it ran a partial command and the exit-code marker was lost; " + + "send a shorter command, or upload it with `dwshell put` and run it by path") + +// probeLine is typed as its own short line right after the command. A shell +// reads it only once it has read and run the command line, so its marker can +// never come back before that command's RC sentinel — unless the remote +// truncated the command line and took the sentinel with it. That makes a lost +// sentinel detectable on any remote, whatever its line limit happens to be. +// +// Like the RC sentinel, the marker is assembled by the remote (`$?` / +// %errorlevel%) so that the PTY echoing the typed line back, or a command that +// reads its stdin and prints it, cannot be mistaken for the marker itself. +func probeLine(os remote.OS) string { + if os == remote.OSWindows { + return "echo __DWSH_TRUNC_%errorlevel%_END__\r" + } + return "echo __DWSH_TRUNC_$?_END__\r" +} + // Run executes a single command non-interactively and returns its output and // exit code. It opens a fresh shell, sends the wrapped command, and reads until // the RC sentinel (or ctx/timeout fires). @@ -57,6 +83,9 @@ func Run(ctx context.Context, sess *session.Session, os remote.OS, command strin if err := sh.Input(wrapCommand(command, os)); err != nil { return nil, err } + if err := sh.Input(probeLine(os)); err != nil { + return nil, err + } var buf bytes.Buffer @@ -90,6 +119,11 @@ func Run(ctx context.Context, sess *session.Session, os remote.OS, command strin } return res, err } + // The probe came back but the sentinel never did: the remote cut + // the command line short and ran what was left of it. + if reTrunc.Match(buf.Bytes()) { + return nil, errTruncated + } } } } diff --git a/internal/app/shell/run_test.go b/internal/app/shell/run_test.go new file mode 100644 index 0000000..0cc41b5 --- /dev/null +++ b/internal/app/shell/run_test.go @@ -0,0 +1,35 @@ +package shell + +import ( + "testing" + + "github.com/porech/dwshell/internal/remote" +) + +// The probe marker must be assembled by the remote shell, so that neither the +// PTY echoing the typed line back nor a command printing its own stdin can be +// mistaken for the marker. +func TestProbeMarkerCannotMatchTheTypedLine(t *testing.T) { + for _, os := range []remote.OS{remote.OSWindows, remote.OSLinux} { + typed := probeLine(os) + if reTrunc.MatchString(typed) { + t.Errorf("the typed probe line %q matches the marker regexp", typed) + } + } +} + +func TestProbeMarkerMatchesWhatTheShellPrints(t *testing.T) { + if !reTrunc.MatchString("__DWSH_TRUNC_0_END__\r\n") { + t.Error("the printed probe marker is not recognised") + } +} + +// A probe line has to survive the very truncation it detects, so it must stay +// far below any line limit a remote shell might impose. +func TestProbeLineIsShort(t *testing.T) { + for _, os := range []remote.OS{remote.OSWindows, remote.OSLinux} { + if n := len(probeLine(os)); n > 100 { + t.Errorf("probe line for %v is %d characters, too long to survive truncation", os, n) + } + } +}