Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,11 @@ DWSHELL_REMOTE_PASSWORD=… dwshell alice@myhost -c "id"

- `-c <command>` — 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 `<host>` among owned agents / incoming shares only.
- `--term <value>` — TERM to send to a *nix remote (default: your local `$TERM`).
- `--no-term` — do not send a TERM to the remote.
Expand Down
34 changes: 34 additions & 0 deletions internal/app/shell/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package shell
import (
"bytes"
"context"
"errors"
"fmt"
"regexp"
"strconv"
Expand All @@ -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.
Expand All @@ -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).
Expand All @@ -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

Expand Down Expand Up @@ -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
}
}
}
}
Expand Down
35 changes: 35 additions & 0 deletions internal/app/shell/run_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}