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
25 changes: 25 additions & 0 deletions internal/adapter/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,28 @@ func TestShellQuote(t *testing.T) {
})
}
}

func TestRemotePathExpr(t *testing.T) {
tests := []struct {
input string
want string
}{
{"~/public_html", "\"$HOME\"/'public_html'"},
{"~", "\"$HOME\""},
{"~/", "\"$HOME\""},
{"~/sites/foo", "\"$HOME\"/'sites/foo'"},
{"/home/user/public_html", "'/home/user/public_html'"},
{"path with spaces", "'path with spaces'"},
{"it's", "'it'\\''s'"},
{"~/it's", "\"$HOME\"/'it'\\''s'"},
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := remotePathExpr(tt.input)
if got != tt.want {
t.Errorf("remotePathExpr(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
27 changes: 22 additions & 5 deletions internal/adapter/standard.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import (
"context"
"fmt"
"os"
"strings"
"time"

"github.com/builtbyrobben/wpssh/internal/registry"
internalssh "github.com/builtbyrobben/wpssh/internal/ssh"
)

// StandardAdapter handles cPanel, VPS, and other standard SSH hosts.
// Commands run via "cd {wp_path} && wp {command}".
// Exec runs pre-built shell commands from wpcli.Command.Build.
// File transfers use stdin/stdout streaming (cat).
type StandardAdapter struct{}

Expand All @@ -27,12 +28,11 @@ func (a *StandardAdapter) Capabilities() AdapterCapabilities {
}
}

// Exec runs a wp-cli command on a standard host.
// Wraps the command as: cd {wp_path} && wp {wpCmd}
// Exec runs a pre-built remote shell command on a standard host.
// Callers (via wpcli.Command.Build) already include the cd + wp prefix.
func (a *StandardAdapter) Exec(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string) (internalssh.ExecResult, error) {
cfg := siteToClientConfig(site)
cmd := fmt.Sprintf("cd %s && wp %s", shellQuote(site.WPPath), wpCmd)
return client.Exec(ctx, cfg, site.CanonicalHost, cmd)
return client.Exec(ctx, cfg, site.CanonicalHost, wpCmd)
}

// Upload streams a local file to the remote host via stdin.
Expand Down Expand Up @@ -85,6 +85,23 @@ func siteToClientConfig(site *registry.Site) internalssh.ClientConfig {
}
}

// remotePathExpr returns a shell-safe remote path expression.
// Leading ~/ is rewritten to "$HOME"/'…' so tilde expansion still works
// after quoting. Absolute and other paths stay single-quoted.
func remotePathExpr(path string) string {
if path == "~" {
return "\"$HOME\""
}
if strings.HasPrefix(path, "~/") {
rest := strings.TrimPrefix(path, "~/")
if rest == "" {
return "\"$HOME\""
}
return "\"$HOME\"/" + shellQuote(rest)
}
return shellQuote(path)
}

// shellQuote wraps a string in single quotes for safe shell usage.
// Embedded single quotes are escaped with the shell idiom:
//
Expand Down
16 changes: 4 additions & 12 deletions internal/adapter/wpengine.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,12 @@ func (a *WPEngineAdapter) Capabilities() AdapterCapabilities {
}
}

// Exec runs a wp-cli command on WP Engine.
// WP Engine's SSH environment places sites at ~/sites/{user}/.
// The command is wrapped as: cd ~/sites/{user}/ && wp {wpCmd}
// Exec runs a pre-built remote shell command on WP Engine.
// Callers (via wpcli.Command.Build) already include the cd + wp prefix.
// WP Engine's SSH environment places sites at ~/sites/{user}/ when configured.
func (a *WPEngineAdapter) Exec(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string) (internalssh.ExecResult, error) {
cfg := wpengineClientConfig(site)

wpPath := site.WPPath
if wpPath == "" {
// WP Engine default: ~/sites/{user}/
wpPath = fmt.Sprintf("~/sites/%s", site.User)
}

cmd := fmt.Sprintf("cd %s && wp %s", shellQuote(wpPath), wpCmd)

// Enforce WP Engine's 10-minute session timeout.
timeout := 10 * time.Minute
if deadline, ok := ctx.Deadline(); ok {
Expand All @@ -59,7 +51,7 @@ func (a *WPEngineAdapter) Exec(ctx context.Context, client *internalssh.SSHClien
execCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

return client.Exec(execCtx, cfg, site.CanonicalHost, cmd)
return client.Exec(execCtx, cfg, site.CanonicalHost, wpCmd)
}

// Upload transfers a local file to WP Engine via stdin piping.
Expand Down
79 changes: 79 additions & 0 deletions internal/registry/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,85 @@ func TestExpandHome(t *testing.T) {
}
}

func TestParseSSHConfigReader_HostSeparators(t *testing.T) {
sshConfig := `
Host ordinary
Host=equals
Host = spaced
Host ==leading-equals
`
entries, err := ParseSSHConfigReader(strings.NewReader(sshConfig))
if err != nil {
t.Fatalf("ParseSSHConfigReader: %v", err)
}

for _, alias := range []string{"ordinary", "equals", "spaced", "=leading-equals"} {
if findEntry(entries, alias) == nil {
t.Errorf("expected alias %q", alias)
}
}
}

func TestParseSSHConfigReader_StripsMatchExecGarbage(t *testing.T) {
sshConfig := `
Host realhost
HostName example.com
User deploy
Port 22

Match host macbook exec "nc -G 1 -z 192.168.1.52 22"
HostName 192.168.1.52
User localuser

Host aftermatch
HostName after.example.com
User after

Match all
User nobody

Host=equalsmatch
HostName equals.example.com
`
entries, err := ParseSSHConfigReader(strings.NewReader(sshConfig))
if err != nil {
t.Fatalf("ParseSSHConfigReader: %v", err)
}

if findEntry(entries, "realhost") == nil {
t.Fatal("expected realhost entry")
}
if findEntry(entries, "aftermatch") == nil {
t.Fatal("expected aftermatch entry")
}
if findEntry(entries, "equalsmatch") == nil {
t.Fatal("expected equalsmatch entry")
}

garbage := []string{"nc", "-G", "-z", "1", "22", "192.168.1.52", "macbook"}
for _, alias := range garbage {
if findEntry(entries, alias) != nil {
t.Errorf("unexpected garbage alias %q in entries", alias)
}
}
}

func TestIsValidSSHHostAlias(t *testing.T) {
valid := []string{"omahadentists", "mini-ts", "github.com", "123"}
for _, alias := range valid {
if !isValidSSHHostAlias(alias) {
t.Errorf("expected valid alias %q", alias)
}
}

invalid := []string{"", "*", "*.example.com", "!negated", "-G", "-z", "\"nc"}
for _, alias := range invalid {
if isValidSSHHostAlias(alias) {
t.Errorf("expected invalid alias %q", alias)
}
}
}

func findEntry(entries []SSHEntry, alias string) *SSHEntry {
for i := range entries {
if entries[i].Alias == alias {
Expand Down
79 changes: 76 additions & 3 deletions internal/registry/sshconfig.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package registry

import (
"bufio"
"io"
"os"
"path/filepath"
Expand Down Expand Up @@ -41,7 +42,13 @@ func ParseSSHConfigFile(path string) ([]SSHEntry, error) {

// ParseSSHConfigReader parses an SSH config from an io.Reader.
func ParseSSHConfigReader(r io.Reader) ([]SSHEntry, error) {
cfg, err := ssh_config.Decode(r)
// kevinburke/ssh_config mis-parses Match exec lines into fake Host
// aliases (e.g. "nc, -G, -z, ports). Strip Match blocks first.
filtered, err := stripSSHMatchBlocks(r)
if err != nil {
return nil, err
}
cfg, err := ssh_config.Decode(filtered)
if err != nil {
return nil, err
}
Expand All @@ -55,8 +62,8 @@ func extractEntries(cfg *ssh_config.Config) ([]SSHEntry, error) {
for _, pattern := range host.Patterns {
alias := pattern.String()

// Skip wildcard entries and negated patterns.
if alias == "*" || strings.ContainsAny(alias, "*?") || strings.HasPrefix(alias, "!") {
// Skip wildcards, negated patterns, and Match-exec garbage.
if !isValidSSHHostAlias(alias) {
continue
}

Expand Down Expand Up @@ -96,6 +103,72 @@ func extractEntries(cfg *ssh_config.Config) ([]SSHEntry, error) {
return entries, nil
}

// stripSSHMatchBlocks removes OpenSSH Match blocks so they are not
// misinterpreted as Host entries by the SSH config parser.
func stripSSHMatchBlocks(r io.Reader) (io.Reader, error) {
var b strings.Builder
scanner := bufio.NewScanner(r)
// Match lines can be long; raise the default token limit.
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
inMatch := false
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
directive := trimmed
arguments := ""
if separator := strings.IndexAny(trimmed, " \t="); separator >= 0 {
directive = trimmed[:separator]
arguments = strings.TrimLeft(trimmed[separator:], " \t")
arguments = strings.TrimPrefix(arguments, "=")
arguments = strings.TrimLeft(arguments, " \t")
}
directive = strings.ToLower(directive)

if directive == "match" {
inMatch = true
continue
}
if inMatch {
// A new Host starts a real host block and ends Match scope.
if directive == "host" {
inMatch = false
} else {
// Still inside Match body (keywords, blanks, comments).
continue
}
}
if !inMatch {
if directive == "host" {
line = "Host"
if arguments != "" {
line += "=" + arguments
}
}
b.WriteString(line)
b.WriteByte('\n')
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return strings.NewReader(b.String()), nil
}

// isValidSSHHostAlias rejects wildcards and garbage tokens leaked from Match exec lines.
func isValidSSHHostAlias(alias string) bool {
if alias == "" || alias == "*" {
return false
}
if strings.ContainsAny(alias, "*?") || strings.HasPrefix(alias, "!") {
return false
}
// Flags / quoted fragments from Match exec "nc -G 1 -z host port"
if strings.HasPrefix(alias, "-") || strings.Contains(alias, "\"") {
return false
}
return true
}

// expandHome replaces ~ prefix with the user's home directory.
func expandHome(path string) string {
if !strings.HasPrefix(path, "~") {
Expand Down
16 changes: 14 additions & 2 deletions internal/wpcli/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,12 @@ func (c *Command) Arg(value string) *Command {
}

// Build returns the full command string: "cd {wpPath} && wp {parts} {args} {flags}"
// All values are shell-escaped using single quotes.
// Path expressions preserve remote ~/ expansion; other values are single-quoted.
func (c *Command) Build(wpPath string) string {
var b strings.Builder

b.WriteString("cd ")
b.WriteString(shellEscape(wpPath))
b.WriteString(pathEscape(wpPath))
b.WriteString(" && wp")

for _, p := range c.parts {
Expand Down Expand Up @@ -131,6 +131,18 @@ func (c *Command) CacheKey() string {
return strings.Join(parts, ":")
}

// pathEscape returns a shell-safe remote path expression.
// Leading ~/ becomes "$HOME"/'…' so tilde expansion still works remotely.
func pathEscape(path string) string {
if path == "~" || path == "~/" {
return "\"$HOME\""
}
if strings.HasPrefix(path, "~/") {
return "\"$HOME\"/" + shellEscape(strings.TrimPrefix(path, "~/"))
}
return shellEscape(path)
}

// shellEscape wraps a value in single quotes, escaping any single quotes
// within the value. This prevents shell interpolation attacks.
func shellEscape(s string) string {
Expand Down
29 changes: 29 additions & 0 deletions internal/wpcli/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,32 @@ func TestShellEscapeFunction(t *testing.T) {
}
}
}

func TestPathEscape(t *testing.T) {
tests := []struct {
input string
want string
}{
{"~/public_html", "\"$HOME\"/'public_html'"},
{"~", "\"$HOME\""},
{"~/", "\"$HOME\""},
{"~/sites/foo", "\"$HOME\"/'sites/foo'"},
{"/var/www/html", "'/var/www/html'"},
{"/home/user/my site", "'/home/user/my site'"},
}

for _, tt := range tests {
got := pathEscape(tt.input)
if got != tt.want {
t.Errorf("pathEscape(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

func TestBuildTildePath(t *testing.T) {
got := New("core", "version").Build("~/public_html")
want := "cd \"$HOME\"/'public_html' && wp core version"
if got != want {
t.Errorf("Build(tilde) = %q, want %q", got, want)
}
}
Loading