2018-01-12 17:30:54 +01:00
// Package fspath contains routines for fspath manipulation
package fspath
2017-06-07 13:27:33 +02:00
import (
2019-09-05 12:01:04 +02:00
"errors"
2017-06-07 13:27:33 +02:00
"path"
2018-06-18 11:39:00 +02:00
"path/filepath"
"regexp"
2018-10-09 13:35:27 +02:00
"strings"
2018-06-18 11:39:00 +02:00
2021-02-09 10:30:40 +01:00
"github.com/rclone/rclone/fs/config/configmap"
2019-07-28 19:47:38 +02:00
"github.com/rclone/rclone/fs/driveletter"
2017-06-07 13:27:33 +02:00
)
2019-09-05 12:01:04 +02:00
const (
2023-01-19 21:38:24 +01:00
configNameRe = ` [\w\p { L}\p { N}.+@]+(?:[ -]+[\w\p { L}\p { N}.+@-]+)* ` // May contain Unicode numbers and letters, as well as `_` (covered by \w), `-`, `.`, `+`, `@` and space, but not start with `-` (it complicates usage, see #4261) or space, and not end with space
illegalPartOfConfigNameRe = ` ^[ -]+|[^\w\p { L}\p { N}.+@ -]+|[ ]+$ `
2019-09-05 12:01:04 +02:00
)
var (
2023-01-19 21:38:24 +01:00
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" )
2020-05-19 13:34:23 +02:00
errCantBeEmpty = errors . New ( "can't use empty string as a path" )
2021-02-09 10:30:40 +01:00
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" )
2019-09-05 12:01:04 +02:00
// configNameMatcher is a pattern to match an rclone config name
configNameMatcher = regexp . MustCompile ( ` ^ ` + configNameRe + ` $ ` )
2022-12-12 20:05:12 +01:00
// illegalPartOfConfigNameMatcher is a pattern to match a sequence of characters not allowed in an rclone config name
illegalPartOfConfigNameMatcher = regexp . MustCompile ( illegalPartOfConfigNameRe )
2021-02-09 10:30:40 +01:00
// remoteNameMatcher is a pattern to match an rclone remote name at the start of a config
2022-09-16 16:20:19 +02:00
remoteNameMatcher = regexp . MustCompile ( ` ^:? ` + configNameRe + ` (?::$|,) ` )
2019-09-05 12:01:04 +02:00
)
// CheckConfigName returns an error if configName is invalid
func CheckConfigName ( configName string ) error {
if ! configNameMatcher . MatchString ( configName ) {
return errInvalidCharacters
}
return nil
}
2022-12-12 20:05:12 +01:00
// 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 , "_" )
}
2021-02-09 10:30:40 +01:00
// checkRemoteName returns an error if remoteName is invalid
func checkRemoteName ( remoteName string ) error {
if remoteName == ":" || remoteName == "::" {
return errConfigNameEmpty
}
2019-09-05 12:01:04 +02:00
if ! remoteNameMatcher . MatchString ( remoteName ) {
return errInvalidCharacters
}
return nil
}
2018-06-18 11:39:00 +02:00
2021-02-09 10:30:40 +01:00
// 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
2018-06-18 11:39:00 +02:00
//
2021-02-09 10:30:40 +01:00
// 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
2018-06-18 11:39:00 +02:00
//
2021-02-09 10:30:40 +01:00
// 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"}
2018-06-18 11:39:00 +02:00
//
// Note that this will turn \ into / in the fsPath on Windows
2019-09-05 12:01:04 +02:00
//
2021-02-09 10:30:40 +01:00
// 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 ) {
2021-03-22 17:25:19 +01:00
parsed . Path = filepath . ToSlash ( path )
2020-05-19 13:34:23 +02:00
if path == "" {
2021-02-09 10:30:40 +01:00
return parsed , errCantBeEmpty
}
// If path has no `:` in, it must be a local path
2022-06-08 22:25:17 +02:00
if ! strings . ContainsRune ( path , ':' ) {
2021-02-09 10:30:40 +01:00
return parsed , nil
2020-05-19 13:34:23 +02:00
}
2021-02-09 10:30:40 +01:00
// 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
2021-03-22 17:25:19 +01:00
if driveletter . IsDriveLetter ( parsed . Name ) {
2021-02-09 10:30:40 +01:00
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 {
2022-05-16 18:11:45 +02:00
value = strings . ReplaceAll ( value , string ( quote ) + string ( quote ) , string ( quote ) )
2021-02-09 10:30:40 +01:00
}
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
}
2019-09-05 12:01:04 +02:00
}
2021-02-09 10:30:40 +01:00
2018-06-18 11:39:00 +02:00
}
2021-02-09 10:30:40 +01:00
// 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 : ]
2018-06-18 11:39:00 +02:00
// change native directory separators to / if there are any
2021-02-09 10:30:40 +01:00
parsed . Path = filepath . ToSlash ( parsed . Path )
return parsed , nil
2018-06-18 11:39:00 +02:00
}
2021-02-10 15:26:30 +01:00
// 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
}
2018-06-18 11:39:00 +02:00
// Split splits a remote into a parent and a leaf
2017-06-07 13:27:33 +02:00
//
// 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
2018-06-18 11:39:00 +02:00
// (except under Windows where \ will be translated into /)
2019-09-05 12:01:04 +02:00
func Split ( remote string ) ( parent string , leaf string , err error ) {
2021-02-10 15:26:30 +01:00
remoteName , remotePath , err := SplitFs ( remote )
2019-09-05 12:01:04 +02:00
if err != nil {
return "" , "" , err
}
2017-06-07 13:27:33 +02:00
// Construct new remote name without last segment
parent , leaf = path . Split ( remotePath )
2019-09-05 12:01:04 +02:00
return remoteName + parent , leaf , nil
2017-06-07 13:27:33 +02:00
}
2018-10-09 13:35:27 +02:00
2020-12-13 11:26:13 +01:00
// 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
2020-09-01 18:55:06 +02:00
//
2020-12-13 11:26:13 +01:00
// 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.
2020-09-01 18:55:06 +02:00
//
// If the path contains \ these will be converted to / on Windows.
2020-12-13 11:26:13 +01:00
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 )
2020-09-01 18:55:06 +02:00
}
2021-02-09 10:30:40 +01:00
parsed , err := Parse ( remote )
remoteName , remotePath := parsed . ConfigString , parsed . Path
2020-12-13 11:26:13 +01:00
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 = ""
2018-10-09 13:35:27 +02:00
}
}
2020-12-13 11:26:13 +01:00
return remoteName + remotePath
2018-10-09 13:35:27 +02:00
}