// Package fspath contains routines for fspath manipulation package fspath import ( "errors" "path" "path/filepath" "regexp" "strings" "github.com/rclone/rclone/fs/config/configmap" "github.com/rclone/rclone/fs/driveletter" ) const ( configNameRe = `[\w\p{L}\p{N}.]+(?:[ -]+[\w\p{L}\p{N}.-]+)*` // May contain Unicode numbers and letters, as well as `_`, `-`, `.` and space, but not start with `-` (it complicates usage, see #4261) or space, and not end with space illegalPartOfConfigNameRe = `^[ -]+|[^\w\p{L}\p{N}. -]+|[ ]+$` ) var ( errInvalidCharacters = errors.New("config name contains invalid characters - may only contain numbers, letters, `_`, `-`, `.` and space, while not start with `-` or space, and not end with space") errCantBeEmpty = errors.New("can't use empty string as a path") errBadConfigParam = errors.New("config parameters may only contain `0-9`, `A-Z`, `a-z` and `_`") errEmptyConfigParam = errors.New("config parameters can't be empty") errConfigNameEmpty = errors.New("config name can't be empty") errConfigName = errors.New("config name needs a trailing `:`") errParam = errors.New("config parameter must end with `,` or `:`") errValue = errors.New("unquoted config value must end with `,` or `:`") errQuotedValue = errors.New("unterminated quoted config value") errAfterQuote = errors.New("expecting `:` or `,` or another quote after a quote") errSyntax = errors.New("syntax error in config string") // configNameMatcher is a pattern to match an rclone config name configNameMatcher = regexp.MustCompile(`^` + configNameRe + `$`) // illegalPartOfConfigNameMatcher is a pattern to match a sequence of characters not allowed in an rclone config name illegalPartOfConfigNameMatcher = regexp.MustCompile(illegalPartOfConfigNameRe) // remoteNameMatcher is a pattern to match an rclone remote name at the start of a config remoteNameMatcher = regexp.MustCompile(`^:?` + configNameRe + `(?::$|,)`) ) // CheckConfigName returns an error if configName is invalid func CheckConfigName(configName string) error { if !configNameMatcher.MatchString(configName) { return errInvalidCharacters } return nil } // MakeConfigName makes an input into something legal to be used as a config name. // Returns a string where any sequences of illegal characters are replaced with // a single underscore. If the input is already valid as a config name, it is // returned unchanged. If the input is an empty string, a single underscore is // returned. func MakeConfigName(name string) string { if name == "" { return "_" } if configNameMatcher.MatchString(name) { return name } return illegalPartOfConfigNameMatcher.ReplaceAllString(name, "_") } // checkRemoteName returns an error if remoteName is invalid func checkRemoteName(remoteName string) error { if remoteName == ":" || remoteName == "::" { return errConfigNameEmpty } if !remoteNameMatcher.MatchString(remoteName) { return errInvalidCharacters } return nil } // Return true if c is a valid character for a config parameter func isConfigParam(c rune) bool { return ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') } // Parsed is returned from Parse with the results of the connection string decomposition // // If Name is "" then it is a local path in Path // // Note that ConfigString + ":" + Path is equal to the input of Parse except that Path may have had // \ converted to / type Parsed struct { Name string // Just the name of the config: "remote" or ":backend" ConfigString string // The whole config string: "remote:" or ":backend,value=6:" Path string // The file system path, may be empty Config configmap.Simple // key/value config parsed out of ConfigString may be nil } // Parse deconstructs a path into a Parsed structure // // If the path is a local path then parsed.Name will be returned as "". // // So "remote:path/to/dir" will return Parsed{Name:"remote", Path:"path/to/dir"}, // and "/path/to/local" will return Parsed{Name:"", Path:"/path/to/local"} // // Note that this will turn \ into / in the fsPath on Windows // // An error may be returned if the remote name has invalid characters or the // parameters are invalid or the path is empty. func Parse(path string) (parsed Parsed, err error) { parsed.Path = filepath.ToSlash(path) if path == "" { return parsed, errCantBeEmpty } // If path has no `:` in, it must be a local path if !strings.ContainsRune(path, ':') { return parsed, nil } // States for parser const ( stateConfigName = uint8(iota) stateParam stateValue stateQuotedValue stateAfterQuote stateDone ) var ( state = stateConfigName // current state of parser i int // position in path prev int // previous position in path c rune // current rune under consideration quote rune // kind of quote to end this quoted string param string // current parameter value doubled bool // set if had doubled quotes ) loop: for i, c = range path { // Example Parse // remote,param=value,param2="qvalue":/path/to/file switch state { // Parses "remote," case stateConfigName: if i == 0 && c == ':' { continue } else if c == '/' || c == '\\' { // `:` or `,` not before a path separator must be a local path, // except if the path started with `:` in which case it was intended // to be an on the fly remote so return an error. if path[0] == ':' { return parsed, errInvalidCharacters } return parsed, nil } else if c == ':' || c == ',' { parsed.Name = path[:i] err := checkRemoteName(parsed.Name + ":") if err != nil { return parsed, err } prev = i + 1 if c == ':' { // If we parsed a drive letter, must be a local path if driveletter.IsDriveLetter(parsed.Name) { parsed.Name = "" return parsed, nil } state = stateDone break loop } state = stateParam parsed.Config = make(configmap.Simple) } // Parses param= and param2= case stateParam: if c == ':' || c == ',' || c == '=' { param = path[prev:i] if len(param) == 0 { return parsed, errEmptyConfigParam } prev = i + 1 if c == '=' { state = stateValue break } parsed.Config[param] = "true" if c == ':' { state = stateDone break loop } state = stateParam } else if !isConfigParam(c) { return parsed, errBadConfigParam } // Parses value case stateValue: if c == '\'' || c == '"' { if i == prev { quote = c state = stateQuotedValue prev = i + 1 doubled = false break } } else if c == ':' || c == ',' { value := path[prev:i] prev = i + 1 parsed.Config[param] = value if c == ':' { state = stateDone break loop } state = stateParam } // Parses "qvalue" case stateQuotedValue: if c == quote { state = stateAfterQuote } // Parses : or , or quote after "qvalue" case stateAfterQuote: if c == ':' || c == ',' { value := path[prev : i-1] // replace any doubled quotes if there were any if doubled { value = strings.ReplaceAll(value, string(quote)+string(quote), string(quote)) } prev = i + 1 parsed.Config[param] = value if c == ':' { state = stateDone break loop } else { state = stateParam } } else if c == quote { // Here is a doubled quote to indicate a literal quote state = stateQuotedValue doubled = true } else { return parsed, errAfterQuote } } } // Depending on which state we were in when we fell off the // end of the state machine we can return a sensible error. switch state { default: return parsed, errSyntax case stateConfigName: return parsed, errConfigName case stateParam: return parsed, errParam case stateValue: return parsed, errValue case stateQuotedValue: return parsed, errQuotedValue case stateAfterQuote: return parsed, errAfterQuote case stateDone: break } parsed.ConfigString = path[:i] parsed.Path = path[i+1:] // change native directory separators to / if there are any parsed.Path = filepath.ToSlash(parsed.Path) return parsed, nil } // SplitFs splits a remote a remoteName and an remotePath. // // SplitFs("remote:path/to/file") -> ("remote:", "path/to/file") // SplitFs("/to/file") -> ("", "/to/file") // // If it returns remoteName as "" then remotePath is a local path // // The returned values have the property that remoteName + remotePath == // remote (except under Windows where \ will be translated into /) func SplitFs(remote string) (remoteName string, remotePath string, err error) { parsed, err := Parse(remote) if err != nil { return "", "", err } remoteName, remotePath = parsed.ConfigString, parsed.Path if remoteName != "" { remoteName += ":" } return remoteName, remotePath, nil } // Split splits a remote into a parent and a leaf // // if it returns leaf as an empty string then remote is a directory // // if it returns parent as an empty string then that means the current directory // // The returned values have the property that parent + leaf == remote // (except under Windows where \ will be translated into /) func Split(remote string) (parent string, leaf string, err error) { remoteName, remotePath, err := SplitFs(remote) if err != nil { return "", "", err } // Construct new remote name without last segment parent, leaf = path.Split(remotePath) return remoteName + parent, leaf, nil } // Make filePath absolute so it can't read above the root func makeAbsolute(filePath string) string { leadingSlash := strings.HasPrefix(filePath, "/") filePath = path.Join("/", filePath) if !leadingSlash && strings.HasPrefix(filePath, "/") { filePath = filePath[1:] } return filePath } // JoinRootPath joins filePath onto remote // // If the remote has a leading "//" this is preserved to allow Windows // network paths to be used as remotes. // // If filePath is empty then remote will be returned. // // If the path contains \ these will be converted to / on Windows. func JoinRootPath(remote, filePath string) string { remote = filepath.ToSlash(remote) if filePath == "" { return remote } filePath = filepath.ToSlash(filePath) filePath = makeAbsolute(filePath) if strings.HasPrefix(remote, "//") { return "/" + path.Join(remote, filePath) } parsed, err := Parse(remote) remoteName, remotePath := parsed.ConfigString, parsed.Path if err != nil { // Couldn't parse so assume it is a path remoteName = "" remotePath = remote } remotePath = path.Join(remotePath, filePath) if remoteName != "" { remoteName += ":" // if have remote: then normalise the remotePath if remotePath == "." { remotePath = "" } } return remoteName + remotePath }