Skip to content
Open
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
12 changes: 4 additions & 8 deletions pkg/agent/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package agent
import (
"fmt"
"os"
"runtime"

"github.com/google/uuid"

Expand Down Expand Up @@ -81,13 +80,10 @@ func install(logger *logging.Logger, transport Transport, prompter string, cmdEx
return fmt.Errorf("unable to copy agent binary: %w", err)
}

// For cases where we're copying from a Windows system to a POSIX remote,
// invoke "chmod +x" to add executability back to the copied binary. This is
// necessary under the specified circumstances because as soon as the agent
// binary is extracted from the bundle, it will lose its executability bit
// since Windows can't preserve this. This will also be applied to Windows
// POSIX remotes, but a "chmod +x" there will just be a no-op.
if runtime.GOOS == "windows" && posix {
// Ensure that POSIX remotes can execute the copied binary. Copies via
// shell commands do not preserve source file permissions, and Windows
// sources lose executable bits before the copy even starts.
if posix {
if err := prompting.Message(prompter, "Setting agent executability..."); err != nil {
return fmt.Errorf("unable to message prompter: %w", err)
}
Expand Down
79 changes: 61 additions & 18 deletions pkg/agent/transport/ssh/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/mutagen-io/mutagen/pkg/agent"
"github.com/mutagen-io/mutagen/pkg/agent/transport"
"github.com/mutagen-io/mutagen/pkg/filesystem"
"github.com/mutagen-io/mutagen/pkg/process"
"github.com/mutagen-io/mutagen/pkg/ssh"
)
Expand Down Expand Up @@ -66,29 +67,30 @@ func NewTransport(user, host string, port uint16, prompter string) (agent.Transp
func (t *sshTransport) Copy(localPath, remoteName string) error {
// HACK: On Windows, we attempt to use SCP executables that might not
// understand Windows paths because they're designed to run inside a POSIX-
// style environment (e.g. MSYS or Cygwin). To work around this, we run them
// in the same directory as the source file and just pass them the source
// base name. In order to compute the working directory, we need the local
// path to be absolute, but fortunately this is the case anyway for paths
// supplied to agent.Transport.Copy. This works fine on non-Windows-POSIX
// systems as well. We probably don't need this IsAbs sanity check, since
// path behavior is guaranteed by the Transport interface, but it's better
// to have as an invariant check.
// style environment (e.g. MSYS or Cygwin). To work around this, the SCP
// fallback runs in the same directory as the source file and just passes the
// source base name. In order to compute the working directory, we need the
// local path to be absolute, but fortunately this is the case anyway for
// paths supplied to agent.Transport.Copy. This works fine on non-Windows-
// POSIX systems as well. We probably don't need this IsAbs sanity check,
// since path behavior is guaranteed by the Transport interface, but it's
// better to have as an invariant check.
if !filepath.IsAbs(localPath) {
return errors.New("scp source path must be absolute")
}

// OpenSSH 9 and newer use SFTP for SCP by default. Some servers do not
// expand home-relative paths in SFTP and instead resolve them against the
// SSH session's configured working directory. For explicit POSIX home paths,
// stream the file through an SSH command so that the remote shell expands
// $HOME and the configured working directory is irrelevant.
if destination, ok := homeRelativePOSIXDestination(remoteName); ok {
return t.copyViaSSHCommand(localPath, destination)
}

workingDirectory, sourceBase := filepath.Split(localPath)

// Compute the destination URL.
// HACK: Since the remote name is supposed to be relative to the user's home
// directory, we'd ideally want to specify a URL of the form
// [user@]host:~/remoteName, but the ~/ paradigm isn't understood by
// Windows. Consequently, we assume that the default destination for SCP
// copies without a path prefix is the user's home directory, i.e. that the
// default working directory for the SCP receiving process is the user's
// home directory. Since we already make the assumption that the home
// directory is the default working directory for SSH commands, this is a
// reasonable additional assumption.
destinationURL := fmt.Sprintf("%s:%s", t.host, remoteName)
if t.user != "" {
destinationURL = fmt.Sprintf("%s@%s", t.user, destinationURL)
Expand Down Expand Up @@ -122,7 +124,7 @@ func (t *sshTransport) Copy(localPath, remoteName string) error {
// Add locale environment variables.
environment = addLocaleVariables(environment)

// Set prompting environment variables
// Set prompting environment variables.
environment, err = SetPrompterVariables(environment, t.prompter)
if err != nil {
return fmt.Errorf("unable to create prompter environment: %w", err)
Expand All @@ -143,6 +145,47 @@ func (t *sshTransport) Copy(localPath, remoteName string) error {
return nil
}

func homeRelativePOSIXDestination(remoteName string) (string, bool) {
prefix := filesystem.HomeDirectorySpecial + "/"
if !strings.HasPrefix(remoteName, prefix) {
return "", false
}

relativePath := strings.TrimPrefix(remoteName, prefix)
if relativePath == "" {
return "", false
}

return fmt.Sprintf("\"$HOME\"/%s", posixSingleQuote(relativePath)), true
}

func posixSingleQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}

func (t *sshTransport) copyViaSSHCommand(localPath, destination string) error {
source, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("unable to open source file: %w", err)
}
defer source.Close()

copyCommand, err := t.Command(fmt.Sprintf("umask 077 && cat > %s", destination))
if err != nil {
return fmt.Errorf("unable to set up SSH copy command: %w", err)
}
copyCommand.Stdin = source

if _, err = copyCommand.Output(); err != nil {
if message := process.ExtractExitErrorMessage(err); message != "" {
return fmt.Errorf("unable to run SSH copy command: %s", message)
}
return fmt.Errorf("unable to run SSH copy command: %w", err)
}

return nil
}

// Command implements the Command method of agent.Transport.
func (t *sshTransport) Command(command string) (*exec.Cmd, error) {
// Compute the target.
Expand Down
99 changes: 99 additions & 0 deletions pkg/agent/transport/ssh/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,105 @@ func TestCopy(t *testing.T) {
}
}

func TestCopyHomeRelativePathUsesSSHCommand(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell command test is POSIX-only")
}

temporaryDirectory := t.TempDir()
fakeSSH := filepath.Join(temporaryDirectory, "ssh")
fakeSCP := filepath.Join(temporaryDirectory, "scp")
argumentsPath := filepath.Join(temporaryDirectory, "arguments")
copiedPath := filepath.Join(temporaryDirectory, "copied")
sourcePath := filepath.Join(temporaryDirectory, "source")

contents := []byte("agent contents")
if err := os.WriteFile(sourcePath, contents, 0600); err != nil {
t.Fatal("unable to write source file:", err)
}

sshScript := `#!/bin/sh
{
for argument do
printf '%s\n' "$argument"
done
} > "$MUTAGEN_TEST_COPY_ARGUMENTS"
cat > "$MUTAGEN_TEST_COPY_OUTPUT"
`
if err := os.WriteFile(fakeSSH, []byte(sshScript), 0700); err != nil {
t.Fatal("unable to write fake ssh:", err)
}

scpScript := `#!/bin/sh
echo scp invoked > "$MUTAGEN_TEST_COPY_ARGUMENTS"
exit 97
`
if err := os.WriteFile(fakeSCP, []byte(scpScript), 0700); err != nil {
t.Fatal("unable to write fake scp:", err)
}

t.Setenv("MUTAGEN_SSH_PATH", temporaryDirectory)
t.Setenv("MUTAGEN_SSH_CONFIG_PATH", "")
t.Setenv("MUTAGEN_TEST_COPY_ARGUMENTS", argumentsPath)
t.Setenv("MUTAGEN_TEST_COPY_OUTPUT", copiedPath)

transport := &sshTransport{
user: "coder",
host: "example.com",
port: 22,
}
remoteName := filesystem.HomeDirectorySpecial + "/.mutagen-agent-test"
if err := transport.Copy(sourcePath, remoteName); err != nil {
t.Fatal("unable to copy file:", err)
}

copied, err := os.ReadFile(copiedPath)
if err != nil {
t.Fatal("unable to read copied file:", err)
} else if string(copied) != string(contents) {
t.Error("copied file contents do not match")
}

arguments, err := os.ReadFile(argumentsPath)
if err != nil {
t.Fatal("unable to read fake ssh arguments:", err)
}
argumentsString := string(arguments)
if strings.Contains(argumentsString, "scp invoked") {
t.Fatal("copy invoked scp instead of ssh")
}
if !strings.Contains(argumentsString, "coder@example.com") {
t.Error("ssh target was not present in arguments")
}
if !strings.Contains(argumentsString, "umask 077 && cat > \"$HOME\"/'.mutagen-agent-test'") {
t.Error("home-relative copy command was not present in arguments")
}
}

func TestHomeRelativePOSIXDestination(t *testing.T) {
if destination, ok := homeRelativePOSIXDestination("relative"); ok {
t.Errorf("relative path converted unexpectedly: %s", destination)
}

destination, ok := homeRelativePOSIXDestination("~/.mutagen-agent-test")
if !ok {
t.Fatal("home-relative path was not converted")
}
expected := `"$HOME"/'.mutagen-agent-test'`
if destination != expected {
t.Errorf("destination mismatch: expected %s, got %s", expected, destination)
}

destination, ok = homeRelativePOSIXDestination("~/path/with'quote")
if !ok {
t.Fatal("quoted path was not converted")
}
expected = `"$HOME"/'path/with'\''quote'`
if destination != expected {
t.Errorf("quoted destination mismatch: expected %s, got %s", expected, destination)
}
}

func TestCommandOutput(t *testing.T) {
// If localhost SSH support isn't available, then skip this test.
if os.Getenv("MUTAGEN_TEST_SSH") != "true" {
Expand Down
Loading