fspath: Implement a connection string parser #4996

This is implemented as a state machine parser so it can emit sensible
error messages.

It does not use the connection strings elsewhere in rclone yet - see
subsequent commits.

An optional fuzzer is implemented for the Parse function.
This commit is contained in:
Nick Craig-Wood 2021-02-09 09:30:40 +00:00
parent 3b21857097
commit 8a46dd1b57
8 changed files with 613 additions and 70 deletions

View File

@ -277,10 +277,11 @@ func NewFs(ctx context.Context, name, rpath string, m configmap.Mapper) (fs.Fs,
return nil, errors.New("can't point remote at itself - check the value of the remote setting")
}
baseName, basePath, err := fspath.Parse(remote)
parsed, err := fspath.Parse(remote)
if err != nil {
return nil, errors.Wrapf(err, "failed to parse remote %q to wrap", remote)
}
baseName, basePath := parsed.ConfigString, parsed.Path
if baseName != "" {
baseName += ":"
}

View File

@ -208,6 +208,83 @@ To copy files and directories in `https://example.com/path/to/dir` to `/tmp/dir`
To copy files and directories from `example.com` in the relative
directory `path/to/dir` to `/tmp/dir` using sftp.
### Connection strings {#connection-strings}
The above examples can also be written using a connection string
syntax, so instead of providing the arguments as command line
parameters `--http-url https://pub.rclone.org` they are provided as
part of the remote specification as a kind of connection string.
rclone lsd ":http,url='https://pub.rclone.org':"
rclone lsf ":http,url='https://example.com':path/to/dir"
rclone copy ":http,url='https://example.com':path/to/dir" /tmp/dir
rclone copy :sftp,host=example.com:path/to/dir /tmp/dir
These can apply to modify existing remotes as well as create new
remotes with the on the fly syntax. This example is equivalent to
adding the `--drive-shared-with-me` parameter to the remote `gdrive:`.
rclone lsf "gdrive,shared_with_me:path/to/dir"
The major advantage to using the connection string style syntax is
that it only applies the the remote, not to all the remotes of that
type of the command line. A common confusion is this attempt to copy a
file shared on google drive to the normal drive which **does not
work** because the `--drive-shared-with-me` flag applies to both the
source and the destination.
rclone copy --drive-shared-with-me gdrive:shared-file.txt gdrive:
However using the connection string syntax, this does work.
rclone copy "gdrive,shared_with_me:shared-file.txt" gdrive:
The connection strings have the following syntax
remote,parameter=value,parameter2=value2:path/to/dir
:backend,parameter=value,parameter2=value2:path/to/dir
If the `parameter` has a `:` or `,` then it must be placed in quotes `"` or
`'`, so
remote,parameter="colon:value",parameter2="comma,value":path/to/dir
:backend,parameter='colon:value',parameter2='comma,value':path/to/dir
If a quoted value needs to include that quote, then it should be
doubled, so
remote,parameter="with""quote",parameter2='with''quote':path/to/dir
This will make `parameter` be `with"quote` and `parameter2` be
`with'quote`.
If you leave off the `=parameter` then rclone will substitute `=true`
which works very well with flags. For example to use s3 configured in
the environment you could use:
rclone lsd :s3,env_auth:
Which is equivalent to
rclone lsd :s3,env_auth=true:
Note that on the command line you might need to surround these
connection strings with `"` or `'` to stop the shell interpreting any
special characters within them.
If you are a shell master then you'll know which strings are OK and
which aren't, but if you aren't sure then enclose them in `"` and use
`'` as the inside quote. This syntax works on all OSes.
rclone copy ":http,url='https://example.com':path/to/dir" /tmp/dir
On Linux/macOS some characters are still interpreted inside `"`
strings in the shell (notably `\` and `$` and `"`) so if your strings
contain those you can swap the roles of `"` and `'` thus. (This syntax
does not work on Windows.)
rclone copy ':http,url="https://example.com":path/to/dir' /tmp/dir
### Valid remote names
- Remote names may only contain 0-9, A-Z ,a-z ,_ , - and space.
@ -1927,11 +2004,8 @@ so they take exactly the same form.
### Config file ###
You can set defaults for values in the config file on an individual
remote basis. If you want to use this feature, you will need to
discover the name of the config items that you want. The easiest way
is to run through `rclone config` by hand, then look in the config
file to see what the values are (the config file can be found by
looking at the help for `--config` in `rclone help`).
remote basis. The names of the config items are documented in the page
for each backend.
To find the name of the environment variable, you need to set, take
`RCLONE_CONFIG_` + name of remote + `_` + name of config file option
@ -1953,6 +2027,11 @@ mys3:
Note that if you want to create a remote using environment variables
you must create the `..._TYPE` variable as above.
Note also that now rclone has [connectionstrings](#connection-strings),
it is probably easier to use those instead which makes the above example
rclone lsd :s3,access_key_id=XXX,secret_access_key=XXX:
### Precedence
The various different methods of backend configuration are read in

View File

@ -1206,10 +1206,12 @@ func MustFind(name string) *RegInfo {
// ParseRemote deconstructs a path into configName, fsPath, looking up
// the fsName in the config file (returning NotFoundInConfigFile if not found)
func ParseRemote(path string) (fsInfo *RegInfo, configName, fsPath string, err error) {
configName, fsPath, err = fspath.Parse(path)
parsed, err := fspath.Parse(path)
if err != nil {
return nil, "", "", err
}
configName, fsPath = parsed.Name, parsed.Path
// FIXME do something with parsed.Config
var fsName string
var ok bool
if configName != "" {

46
fs/fspath/fuzz.go Normal file
View File

@ -0,0 +1,46 @@
//+build gofuzz
/*
Fuzz test the Parse function
Generate corpus
go test -v -make-corpus
Install go fuzz
go get -u github.com/dvyukov/go-fuzz/go-fuzz github.com/dvyukov/go-fuzz/go-fuzz-build
Compile and fuzz
go-fuzz-build
go-fuzz
Tidy up
rm -rf corpus/ crashers/ suppressions/
git co ../../go.mod ../../go.sum
*/
package fspath
func Fuzz(data []byte) int {
path := string(data)
parsed, err := Parse(path)
if err != nil {
return 0
}
if parsed.Name == "" {
if parsed.ConfigString != "" {
panic("bad ConfigString")
}
if parsed.Path != path {
panic("local path not preserved")
}
} else {
if parsed.ConfigString+":"+parsed.Path != path {
panic("didn't split properly")
}
}
return 0
}

View File

@ -8,28 +8,37 @@ import (
"regexp"
"strings"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/driveletter"
)
const (
configNameRe = `[\w_ -]+`
remoteNameRe = `^(:?` + configNameRe + `):`
remoteNameRe = `^(:?` + configNameRe + `)`
)
var (
errInvalidCharacters = errors.New("config name contains invalid characters - may only contain 0-9, A-Z ,a-z ,_ , - and space")
errInvalidCharacters = errors.New("config name contains invalid characters - may only contain `0-9`, `A-Z`, `a-z`, `_`, `-` and space")
errCantBeEmpty = errors.New("can't use empty string as a path")
errCantStartWithDash = errors.New("config name starts with -")
// urlMatcher is a pattern to match an rclone URL
// note that this matches invalid remoteNames
urlMatcher = regexp.MustCompile(`^(:?[^\\/:]*):(.*)$`)
errCantStartWithDash = errors.New("config name starts with `-`")
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 + `$`)
// remoteNameMatcher is a pattern to match an rclone remote name
remoteNameMatcher = regexp.MustCompile(remoteNameRe + `$`)
// remoteNameMatcher is a pattern to match an rclone remote name at the start of a config
remoteNameMatcher = regexp.MustCompile(`^` + remoteNameRe + `(:$|,)`)
// Function to check if string is a drive letter to be overriden in the tests
isDriveLetter = driveletter.IsDriveLetter
)
// CheckConfigName returns an error if configName is invalid
@ -44,41 +53,210 @@ func CheckConfigName(configName string) error {
return nil
}
// CheckRemoteName returns an error if remoteName is invalid
func CheckRemoteName(remoteName string) error {
// 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
}
// Parse deconstructs a remote path into configName and fsPath
// 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 the path is a local path then configName will be returned as "".
// If Name is "" then it is a local path in Path
//
// So "remote:path/to/dir" will return "remote", "path/to/dir"
// and "/path/to/local" will return ("", "/path/to/local")
// 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
// in it or if the path is empty.
func Parse(path string) (configName, fsPath string, err error) {
// 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 = path
if path == "" {
return "", "", errCantBeEmpty
return parsed, errCantBeEmpty
}
parts := urlMatcher.FindStringSubmatch(path)
configName, fsPath = "", path
if parts != nil && !driveletter.IsDriveLetter(parts[1]) {
configName, fsPath = parts[1], parts[2]
err = CheckRemoteName(configName + ":")
if err != nil {
return configName, fsPath, errInvalidCharacters
// If path has no `:` in, it must be a local path
if strings.IndexRune(path, ':') < 0 {
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 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.Replace(value, string(quote)+string(quote), string(quote), -1)
}
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
fsPath = filepath.ToSlash(fsPath)
return configName, fsPath, nil
parsed.Path = filepath.ToSlash(parsed.Path)
return parsed, nil
}
// Split splits a remote into a parent and a leaf
@ -90,10 +268,11 @@ func Parse(path string) (configName, fsPath string, err error) {
// 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 := Parse(remote)
parsed, err := Parse(remote)
if err != nil {
return "", "", err
}
remoteName, remotePath := parsed.ConfigString, parsed.Path
if remoteName != "" {
remoteName += ":"
}
@ -130,7 +309,8 @@ func JoinRootPath(remote, filePath string) string {
if strings.HasPrefix(remote, "//") {
return "/" + path.Join(remote, filePath)
}
remoteName, remotePath, err := Parse(remote)
parsed, err := Parse(remote)
remoteName, remotePath := parsed.ConfigString, parsed.Path
if err != nil {
// Couldn't parse so assume it is a path
remoteName = ""

View File

@ -1,13 +1,23 @@
package fspath
import (
"flag"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/rclone/rclone/fs/config/configmap"
"github.com/rclone/rclone/fs/driveletter"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
makeCorpus = flag.Bool("make-corpus", false, "Set to make the fuzzing corpus")
)
func TestCheckConfigName(t *testing.T) {
@ -39,6 +49,7 @@ func TestCheckRemoteName(t *testing.T) {
want error
}{
{":remote:", nil},
{":s3:", nil},
{"remote:", nil},
{"", errInvalidCharacters},
{"rem:ote", errInvalidCharacters},
@ -49,44 +60,267 @@ func TestCheckRemoteName(t *testing.T) {
{"[remote:", errInvalidCharacters},
{"*:", errInvalidCharacters},
} {
got := CheckRemoteName(test.in)
got := checkRemoteName(test.in)
assert.Equal(t, test.want, got, test.in)
}
}
func TestParse(t *testing.T) {
for _, test := range []struct {
in, wantConfigName, wantFsPath string
wantErr error
isDriveLetter = func(name string) bool {
return name == "C"
}
defer func() {
isDriveLetter = driveletter.IsDriveLetter
}()
for testNumber, test := range []struct {
in string
wantParsed Parsed
wantErr error
}{
{"", "", "", errCantBeEmpty},
{":", "", "", errInvalidCharacters},
{"::", ":", "", errInvalidCharacters},
{":/:", "", "/:", errInvalidCharacters},
{"/:", "", "/:", nil},
{"\\backslash:", "", "\\backslash:", nil},
{"/slash:", "", "/slash:", nil},
{"with\\backslash:", "", "with\\backslash:", nil},
{"with/slash:", "", "with/slash:", nil},
{"/path/to/file", "", "/path/to/file", nil},
{"/path:/to/file", "", "/path:/to/file", nil},
{"./path:/to/file", "", "./path:/to/file", nil},
{"./:colon.txt", "", "./:colon.txt", nil},
{"path/to/file", "", "path/to/file", nil},
{"remote:path/to/file", "remote", "path/to/file", nil},
{"rem*ote:path/to/file", "rem*ote", "path/to/file", errInvalidCharacters},
{"remote:/path/to/file", "remote", "/path/to/file", nil},
{"rem.ote:/path/to/file", "rem.ote", "/path/to/file", errInvalidCharacters},
{":backend:/path/to/file", ":backend", "/path/to/file", nil},
{":bac*kend:/path/to/file", ":bac*kend", "/path/to/file", errInvalidCharacters},
{
in: "",
wantErr: errCantBeEmpty,
}, {
in: ":",
wantErr: errConfigName,
}, {
in: "::",
wantErr: errConfigNameEmpty,
}, {
in: ":/:",
wantErr: errInvalidCharacters,
}, {
in: "/:",
wantParsed: Parsed{
ConfigString: "",
Path: "/:",
},
}, {
in: "\\backslash:",
wantParsed: Parsed{
ConfigString: "",
Path: "\\backslash:",
},
}, {
in: "/slash:",
wantParsed: Parsed{
ConfigString: "",
Path: "/slash:",
},
}, {
in: "with\\backslash:",
wantParsed: Parsed{
ConfigString: "",
Path: "with\\backslash:",
},
}, {
in: "with/slash:",
wantParsed: Parsed{
ConfigString: "",
Path: "with/slash:",
},
}, {
in: "/path/to/file",
wantParsed: Parsed{
ConfigString: "",
Path: "/path/to/file",
},
}, {
in: "/path:/to/file",
wantParsed: Parsed{
ConfigString: "",
Path: "/path:/to/file",
},
}, {
in: "./path:/to/file",
wantParsed: Parsed{
ConfigString: "",
Path: "./path:/to/file",
},
}, {
in: "./:colon.txt",
wantParsed: Parsed{
ConfigString: "",
Path: "./:colon.txt",
},
}, {
in: "path/to/file",
wantParsed: Parsed{
ConfigString: "",
Path: "path/to/file",
},
}, {
in: "remote:path/to/file",
wantParsed: Parsed{
ConfigString: "remote",
Name: "remote",
Path: "path/to/file",
},
}, {
in: "rem*ote:path/to/file",
wantErr: errInvalidCharacters,
}, {
in: "remote:/path/to/file",
wantParsed: Parsed{
ConfigString: "remote",
Name: "remote",
Path: "/path/to/file",
},
}, {
in: "rem.ote:/path/to/file",
wantErr: errInvalidCharacters,
}, {
in: ":backend:/path/to/file",
wantParsed: Parsed{
ConfigString: ":backend",
Name: ":backend",
Path: "/path/to/file",
},
}, {
in: ":bac*kend:/path/to/file",
wantErr: errInvalidCharacters,
}, {
in: `C:\path\to\file`,
wantParsed: Parsed{
Name: "",
Path: `C:\path\to\file`,
},
}, {
in: `remote:\path\to\file`,
wantParsed: Parsed{
Name: "remote",
ConfigString: "remote",
Path: `\path\to\file`,
},
}, {
in: `D:/path/to/file`,
wantParsed: Parsed{
Name: "D",
ConfigString: "D",
Path: `/path/to/file`,
},
}, {
in: `:backend,param1:/path/to/file`,
wantParsed: Parsed{
ConfigString: `:backend,param1`,
Name: ":backend",
Path: "/path/to/file",
Config: configmap.Simple{
"param1": "true",
},
},
}, {
in: `:backend,param1=value:/path/to/file`,
wantParsed: Parsed{
ConfigString: `:backend,param1=value`,
Name: ":backend",
Path: "/path/to/file",
Config: configmap.Simple{
"param1": "value",
},
},
}, {
in: `:backend,param1=value1,param2,param3=value3:/path/to/file`,
wantParsed: Parsed{
ConfigString: `:backend,param1=value1,param2,param3=value3`,
Name: ":backend",
Path: "/path/to/file",
Config: configmap.Simple{
"param1": "value1",
"param2": "true",
"param3": "value3",
},
},
}, {
in: `:backend,param1=value1,param2="value2",param3='value3':/path/to/file`,
wantParsed: Parsed{
ConfigString: `:backend,param1=value1,param2="value2",param3='value3'`,
Name: ":backend",
Path: "/path/to/file",
Config: configmap.Simple{
"param1": "value1",
"param2": "value2",
"param3": "value3",
},
},
}, {
in: `:backend,param-1=value:/path/to/file`,
wantErr: errBadConfigParam,
}, {
in: `:backend,param1="value"x:/path/to/file`,
wantErr: errAfterQuote,
}, {
in: `:backend,`,
wantErr: errParam,
}, {
in: `:backend,param=value`,
wantErr: errValue,
}, {
in: `:backend,param="value'`,
wantErr: errQuotedValue,
}, {
in: `:backend,param1="value"`,
wantErr: errAfterQuote,
}, {
in: `:backend,=value:`,
wantErr: errEmptyConfigParam,
}, {
in: `:backend,:`,
wantErr: errEmptyConfigParam,
}, {
in: `:backend,,:`,
wantErr: errEmptyConfigParam,
}, {
in: `:backend,param=:path`,
wantParsed: Parsed{
ConfigString: `:backend,param=`,
Name: ":backend",
Path: "path",
Config: configmap.Simple{
"param": "",
},
},
}, {
in: `:backend,param="with""quote":path`,
wantParsed: Parsed{
ConfigString: `:backend,param="with""quote"`,
Name: ":backend",
Path: "path",
Config: configmap.Simple{
"param": `with"quote`,
},
},
}, {
in: `:backend,param='''''':`,
wantParsed: Parsed{
ConfigString: `:backend,param=''''''`,
Name: ":backend",
Path: "",
Config: configmap.Simple{
"param": `''`,
},
},
}, {
in: `:backend,param=''bad'':`,
wantErr: errAfterQuote,
},
} {
gotConfigName, gotFsPath, gotErr := Parse(test.in)
if runtime.GOOS == "windows" {
test.wantFsPath = strings.Replace(test.wantFsPath, `\`, `/`, -1)
gotParsed, gotErr := Parse(test.in)
// For non-local paths we convert \ into / on Windows
if runtime.GOOS == "windows" && test.wantParsed.Name != "" {
test.wantParsed.Path = strings.Replace(test.wantParsed.Path, `\`, `/`, -1)
}
assert.Equal(t, test.wantErr, gotErr)
assert.Equal(t, test.wantConfigName, gotConfigName)
assert.Equal(t, test.wantFsPath, gotFsPath)
assert.Equal(t, test.wantErr, gotErr, test.in)
if test.wantErr == nil {
assert.Equal(t, test.wantParsed, gotParsed, test.in)
}
if *makeCorpus {
// write the test corpus for fuzzing
require.NoError(t, os.MkdirAll("corpus", 0777))
require.NoError(t, ioutil.WriteFile(fmt.Sprintf("corpus/%02d", testNumber), []byte(test.in), 0666))
}
}
}

View File

@ -1474,8 +1474,9 @@ func Run(t *testing.T, opt *Opt) {
t.Skip("Can't list from root on this remote")
}
configName, configLeaf, err := fspath.Parse(subRemoteName)
parsed, err := fspath.Parse(subRemoteName)
require.NoError(t, err)
configName, configLeaf := parsed.ConfigString, parsed.Path
if configName == "" {
configName, configLeaf = path.Split(subRemoteName)
} else {

View File

@ -122,11 +122,11 @@ func Start(remoteName string) (fn func(), err error) {
// don't start the local backend
return func() {}, nil
}
var name string
name, _, err = fspath.Parse(remoteName)
parsed, err := fspath.Parse(remoteName)
if err != nil {
return nil, err
}
name := parsed.ConfigString
if name == "" {
// don't start the local backend
return func() {}, nil