clonetool/spec.go
2026-09-25 10:32:17 +02:00

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
}