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 }