144 lines
4.7 KiB
Go
144 lines
4.7 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Spec is a parsed source/dest location. It is one of:
|
|
// - a bare local path (Host == "", Net == false)
|
|
// - an ssh endpoint [user@]host:path (Host != "", Net == false)
|
|
// - a listen endpoint tcp://host:port/path (Net == true) — a clonetool
|
|
// started in "listen" mode that this side connects to over TLS instead
|
|
// of ssh. Only the source is expected to be a listener today.
|
|
type Spec struct {
|
|
Raw string
|
|
User string
|
|
Host string
|
|
Path string
|
|
Net bool // tcp:// listen endpoint (connect to a listening clonetool)
|
|
Port int // listener port, when Net
|
|
}
|
|
|
|
func (s Spec) IsLocal() bool { return s.Host == "" && !s.Net }
|
|
func (s Spec) IsNet() bool { return s.Net }
|
|
|
|
func (s Spec) String() string {
|
|
if s.Net {
|
|
return fmt.Sprintf("tcp://%s:%d/%s", s.Host, s.Port, s.Path)
|
|
}
|
|
if s.IsLocal() {
|
|
return s.Path
|
|
}
|
|
if s.User != "" {
|
|
return fmt.Sprintf("%s@%s:%s", s.User, s.Host, s.Path)
|
|
}
|
|
return fmt.Sprintf("%s:%s", s.Host, s.Path)
|
|
}
|
|
|
|
// looksLikeLocalPath reports whether raw is an ordinary local path that must
|
|
// never be mistaken for a "host:path" spec: a unix absolute/relative path, a
|
|
// Windows drive path (C:\... or C:/...), or a Windows UNC / raw-device path
|
|
// (\\server\share, \\.\PhysicalDrive0).
|
|
func looksLikeLocalPath(raw string) bool {
|
|
if strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "./") || strings.HasPrefix(raw, "../") {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(raw, `\\`) { // UNC or \\.\ device
|
|
return true
|
|
}
|
|
// Windows drive path: a single letter, a colon, then a separator.
|
|
if len(raw) >= 3 && raw[1] == ':' &&
|
|
((raw[0] >= 'A' && raw[0] <= 'Z') || (raw[0] >= 'a' && raw[0] <= 'z')) &&
|
|
(raw[2] == '\\' || raw[2] == '/') {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// parseSpec parses a location: "tcp://host:port/path" (a listen endpoint),
|
|
// "[user@]host:path" (ssh), or a local path. Local paths (unix or Windows,
|
|
// including drive letters and \\.\ devices) and anything without a colon are
|
|
// treated as local so an ordinary path is never mistaken for a host spec.
|
|
func parseSpec(raw string) (Spec, error) {
|
|
if raw == "" {
|
|
return Spec{}, fmt.Errorf("empty location")
|
|
}
|
|
if strings.HasPrefix(raw, "tcp://") {
|
|
return parseNetSpec(raw)
|
|
}
|
|
if looksLikeLocalPath(raw) || !strings.Contains(raw, ":") {
|
|
return Spec{Raw: raw, Path: raw}, nil
|
|
}
|
|
idx := strings.Index(raw, ":")
|
|
hostpart := raw[:idx]
|
|
path := raw[idx+1:]
|
|
if hostpart == "" || path == "" || strings.ContainsAny(hostpart, "/ ") {
|
|
return Spec{}, fmt.Errorf("cannot parse location %q (expected [user@]host:path or a local path)", raw)
|
|
}
|
|
user := ""
|
|
host := hostpart
|
|
if at := strings.Index(hostpart, "@"); at >= 0 {
|
|
user = hostpart[:at]
|
|
host = hostpart[at+1:]
|
|
}
|
|
if host == "" {
|
|
return Spec{}, fmt.Errorf("cannot parse location %q: empty host", raw)
|
|
}
|
|
return Spec{Raw: raw, User: user, Host: host, Path: path}, nil
|
|
}
|
|
|
|
// parseNetSpec parses "tcp://host:port/path". Everything after the first "/"
|
|
// that follows the host:port authority is taken literally as the path, so a
|
|
// Windows path (C:\dir\file, \\.\PhysicalDrive0) survives unmangled.
|
|
func parseNetSpec(raw string) (Spec, error) {
|
|
rest := strings.TrimPrefix(raw, "tcp://")
|
|
slash := strings.IndexByte(rest, '/')
|
|
if slash < 0 {
|
|
return Spec{}, fmt.Errorf("cannot parse %q (expected tcp://host:port/path)", raw)
|
|
}
|
|
authority := rest[:slash]
|
|
path := rest[slash+1:]
|
|
if path == "" {
|
|
return Spec{}, fmt.Errorf("cannot parse %q: empty path", raw)
|
|
}
|
|
colon := strings.LastIndexByte(authority, ':')
|
|
if colon <= 0 || colon == len(authority)-1 {
|
|
return Spec{}, fmt.Errorf("cannot parse %q (expected tcp://host:port/path)", raw)
|
|
}
|
|
host := authority[:colon]
|
|
port, err := strconv.Atoi(authority[colon+1:])
|
|
if err != nil || port <= 0 || port > 65535 {
|
|
return Spec{}, fmt.Errorf("cannot parse %q: invalid port %q", raw, authority[colon+1:])
|
|
}
|
|
return Spec{Raw: raw, Host: host, Port: port, Path: path, Net: true}, nil
|
|
}
|
|
|
|
// checkNotSame does a best-effort local check that source and dest don't
|
|
// refer to the exact same path, to avoid an obviously destructive mistake.
|
|
// It cannot resolve whether two different remote hostnames are actually the
|
|
// same machine.
|
|
func checkNotSame(src, dst Spec) error {
|
|
if src.IsNet() != dst.IsNet() {
|
|
return nil
|
|
}
|
|
if src.IsNet() && (!strings.EqualFold(src.Host, dst.Host) || src.Port != dst.Port) {
|
|
return nil
|
|
}
|
|
if src.IsLocal() != dst.IsLocal() {
|
|
return nil
|
|
}
|
|
if !src.IsLocal() && !src.IsNet() && !strings.EqualFold(src.Host, dst.Host) {
|
|
return nil
|
|
}
|
|
if !src.IsLocal() && src.User != dst.User {
|
|
return nil
|
|
}
|
|
if filepath.Clean(src.Path) == filepath.Clean(dst.Path) {
|
|
return fmt.Errorf("source and destination resolve to the same path (%s)", src.Path)
|
|
}
|
|
return nil
|
|
}
|