From e31b2567b8ac880cfd589f777534e210bffa1846 Mon Sep 17 00:00:00 2001 From: Alan Donovan Date: Mon, 18 Oct 2021 11:26:07 -0400 Subject: [PATCH 1/2] gh cs cp: copy files between local/remote file systems --- internal/codespaces/ssh.go | 18 +++++++++ pkg/cmd/codespace/root.go | 1 + pkg/cmd/codespace/ssh.go | 77 +++++++++++++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/internal/codespaces/ssh.go b/internal/codespaces/ssh.go index 36c8bf5b2..b54dff0dc 100644 --- a/internal/codespaces/ssh.go +++ b/internal/codespaces/ssh.go @@ -25,6 +25,24 @@ func Shell(ctx context.Context, log logger, sshArgs []string, port int, destinat return cmd.Run() } +// Copy runs an scp command over the specified port. The arguments may +// include flags and non-flags, optionally separated by "--". +// Remote files are indicated by a "user@host:" prefix. +func Copy(ctx context.Context, scpArgs []string, port int) error { + // Beware: invalid syntax causes scp to exit 1 with + // no error message, so don't let that happen. + scpArgs = append([]string{ + "-P", strconv.Itoa(port), + "-o", "NoHostAuthenticationForLocalhost=yes", + "-C", // compression + }, scpArgs...) + cmd := exec.CommandContext(ctx, "scp", scpArgs...) + cmd.Stdin = nil + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() +} + // NewRemoteCommand returns an exec.Cmd that will securely run a shell // command on the remote machine. func NewRemoteCommand(ctx context.Context, tunnelPort int, destination string, sshArgs ...string) (*exec.Cmd, error) { diff --git a/pkg/cmd/codespace/root.go b/pkg/cmd/codespace/root.go index efc1c763e..3fed42563 100644 --- a/pkg/cmd/codespace/root.go +++ b/pkg/cmd/codespace/root.go @@ -25,6 +25,7 @@ token to access the GitHub API with.`, root.AddCommand(newLogsCmd(app)) root.AddCommand(newPortsCmd(app)) root.AddCommand(newSSHCmd(app)) + root.AddCommand(newCpCmd(app)) root.AddCommand(newStopCmd(app)) return root diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go index 218494a13..91b8a2e7e 100644 --- a/pkg/cmd/codespace/ssh.go +++ b/pkg/cmd/codespace/ssh.go @@ -1,5 +1,7 @@ package codespace +// This file defines the 'gh cs ssh' and 'gh cs cp' subcommands. + import ( "context" "fmt" @@ -7,6 +9,8 @@ import ( "log" "net" "os" + "path/filepath" + "strings" "github.com/cli/cli/v2/internal/codespaces" "github.com/cli/cli/v2/pkg/liveshare" @@ -19,6 +23,7 @@ type sshOptions struct { serverPort int debug bool debugFile string + scpArgs []string // scp arguments, for 'cs cp' (nil for 'cs ssh') } func newSSHCmd(app *App) *cobra.Command { @@ -117,7 +122,13 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e shellClosed := make(chan error, 1) go func() { - shellClosed <- codespaces.Shell(ctx, a.logger, sshArgs, localSSHServerPort, connectDestination, usingCustomPort) + var err error + if opts.scpArgs != nil { + err = codespaces.Copy(ctx, opts.scpArgs, localSSHServerPort) + } else { + err = codespaces.Shell(ctx, a.logger, sshArgs, localSSHServerPort, connectDestination, usingCustomPort) + } + shellClosed <- err }() select { @@ -131,6 +142,70 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e } } +type cpOptions struct { + sshOptions + recursive bool // -r +} + +func newCpCmd(app *App) *cobra.Command { + var opts cpOptions + + cpCmd := &cobra.Command{ + Use: "cp [-r] srcs... dest", + Short: "Copy files between local and remote file systems", + Long: ` +The cp command copies files between the local and remote file systems. + +A 'remote:' prefix on any file name argument indicates that it refers to +the file system of the remote (Codespace) machine. + +As with the UNIX cp command, the first argument specifies the source and the last +specifies the destination; additional sources may be specified after the first, +if the destination is a directory. + +The -r (recursive) flag is required if any source is a directory. +`, + RunE: func(cmd *cobra.Command, args []string) error { + return app.Copy(cmd.Context(), args, opts) + }, + } + + // We don't expose all sshOptions. + cpCmd.Flags().BoolVarP(&opts.recursive, "recursive", "r", false, "Recursively copy directories") + cpCmd.Flags().StringVarP(&opts.codespace, "codespace", "c", "", "Name of the codespace") + return cpCmd +} + +// Copy copies files between the local and remote file systems. +// The mechanics are similar to 'ssh' but using 'scp'. +func (a *App) Copy(ctx context.Context, args []string, opts cpOptions) (err error) { + if len(args) < 2 { + return fmt.Errorf("cp requires source and destination arguments") + } + if opts.recursive { + opts.scpArgs = append(opts.scpArgs, "-r") + } + opts.scpArgs = append(opts.scpArgs, "--") + for _, arg := range args { + if rest := strings.TrimPrefix(arg, "remote:"); rest != arg { + // TODO(adonovan): don't assume user=root: + // use value from session.StartSSHServer. + arg = "root@localhost:" + rest + } else if !filepath.IsAbs(arg) { + // scp treats a colon in the first path segment as a host identifier. + // Escape it by prepending "./". + // TODO(adonovan): test on Windows, including with a c:\\foo path. + const sep = string(os.PathSeparator) + first := strings.Split(filepath.ToSlash(arg), sep)[0] + if strings.Contains(first, ":") { + arg = "." + sep + arg + } + } + opts.scpArgs = append(opts.scpArgs, arg) + } + return a.SSH(ctx, nil, opts.sshOptions) +} + // fileLogger is a wrapper around an log.Logger configured to write // to a file. It exports two additional methods to get the log file name // and close the file handle when the operation is finished. From 48ada6d5f29460318a69102836f69878d79e441e Mon Sep 17 00:00:00 2001 From: Alan Donovan Date: Mon, 18 Oct 2021 13:59:41 -0400 Subject: [PATCH 2/2] Use correct destination string, not root@localhost --- internal/codespaces/ssh.go | 17 ++++++++++++----- pkg/cmd/codespace/ssh.go | 11 ++++------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/internal/codespaces/ssh.go b/internal/codespaces/ssh.go index b54dff0dc..1807c87fa 100644 --- a/internal/codespaces/ssh.go +++ b/internal/codespaces/ssh.go @@ -27,16 +27,23 @@ func Shell(ctx context.Context, log logger, sshArgs []string, port int, destinat // Copy runs an scp command over the specified port. The arguments may // include flags and non-flags, optionally separated by "--". -// Remote files are indicated by a "user@host:" prefix. -func Copy(ctx context.Context, scpArgs []string, port int) error { +// Remote files are indicated by a "remote:" prefix, and are resolved +// relative to the remote user's home directory. +func Copy(ctx context.Context, scpArgs []string, port int, destination string) error { // Beware: invalid syntax causes scp to exit 1 with // no error message, so don't let that happen. - scpArgs = append([]string{ + cmd := exec.CommandContext(ctx, "scp", "-P", strconv.Itoa(port), "-o", "NoHostAuthenticationForLocalhost=yes", "-C", // compression - }, scpArgs...) - cmd := exec.CommandContext(ctx, "scp", scpArgs...) + ) + for _, arg := range scpArgs { + // Replace "remote:" prefix with (e.g.) "root@localhost:". + if rest := strings.TrimPrefix(arg, "remote:"); rest != arg { + arg = destination + ":" + rest + } + cmd.Args = append(cmd.Args, arg) + } cmd.Stdin = nil cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go index 91b8a2e7e..d0d3e49fe 100644 --- a/pkg/cmd/codespace/ssh.go +++ b/pkg/cmd/codespace/ssh.go @@ -124,7 +124,7 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e go func() { var err error if opts.scpArgs != nil { - err = codespaces.Copy(ctx, opts.scpArgs, localSSHServerPort) + err = codespaces.Copy(ctx, opts.scpArgs, localSSHServerPort, connectDestination) } else { err = codespaces.Shell(ctx, a.logger, sshArgs, localSSHServerPort, connectDestination, usingCustomPort) } @@ -157,7 +157,8 @@ func newCpCmd(app *App) *cobra.Command { The cp command copies files between the local and remote file systems. A 'remote:' prefix on any file name argument indicates that it refers to -the file system of the remote (Codespace) machine. +the file system of the remote (Codespace) machine. It is resolved relative +to the home directory of the remote user. As with the UNIX cp command, the first argument specifies the source and the last specifies the destination; additional sources may be specified after the first, @@ -187,11 +188,7 @@ func (a *App) Copy(ctx context.Context, args []string, opts cpOptions) (err erro } opts.scpArgs = append(opts.scpArgs, "--") for _, arg := range args { - if rest := strings.TrimPrefix(arg, "remote:"); rest != arg { - // TODO(adonovan): don't assume user=root: - // use value from session.StartSSHServer. - arg = "root@localhost:" + rest - } else if !filepath.IsAbs(arg) { + if !filepath.IsAbs(arg) && !strings.HasPrefix(arg, "remote:") { // scp treats a colon in the first path segment as a host identifier. // Escape it by prepending "./". // TODO(adonovan): test on Windows, including with a c:\\foo path.